chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
2
internal/app/stories/doc.go
Normal file
2
internal/app/stories/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package stories contains protocol-neutral story application services.
|
||||
package stories
|
||||
560
internal/app/stories/service.go
Normal file
560
internal/app/stories/service.go
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
package stories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service owns Telegram story read/watch state.
|
||||
type Service struct {
|
||||
stories store.StoryStore
|
||||
channelAccess ChannelStoryAccess
|
||||
}
|
||||
|
||||
// ChannelStoryAccess validates channel story mutation rights without leaking TL
|
||||
// types into the app layer.
|
||||
type ChannelStoryAccess interface {
|
||||
CanPostStory(ctx context.Context, userID, channelID int64) error
|
||||
CanEditStory(ctx context.Context, userID, channelID int64) error
|
||||
CanDeleteStory(ctx context.Context, userID, channelID int64) error
|
||||
CanPinStory(ctx context.Context, userID, channelID int64) error
|
||||
}
|
||||
|
||||
// Option adjusts optional story service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithChannelStoryAccess enables channel story publishing/admin checks.
|
||||
func WithChannelStoryAccess(access ChannelStoryAccess) Option {
|
||||
return func(s *Service) { s.channelAccess = access }
|
||||
}
|
||||
|
||||
// NewService creates a story service.
|
||||
func NewService(stories store.StoryStore, opts ...Option) *Service {
|
||||
s := &Service{stories: stories}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) CreateStory(ctx context.Context, userID int64, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) {
|
||||
if err := s.authorizeStoryOwner(ctx, userID, req.Owner, storyOwnerPost); err != nil {
|
||||
return domain.StoryCreateResult{}, err
|
||||
}
|
||||
if req.RandomID == 0 {
|
||||
return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if req.Media == nil {
|
||||
return domain.StoryCreateResult{}, domain.ErrStoryNotFound
|
||||
}
|
||||
req.Period = normalizeStoryPeriod(req.Period)
|
||||
if req.Period == 0 {
|
||||
return domain.StoryCreateResult{}, domain.ErrStoryPeriodInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryCreateResult{}, domain.ErrStoryNotFound
|
||||
}
|
||||
return s.stories.CreateStory(ctx, req)
|
||||
}
|
||||
|
||||
// UpsertStory stores a story snapshot. It is used by tests now and by the
|
||||
// publishing flow once sendStory/editStory are promoted from stubs.
|
||||
func (s *Service) UpsertStory(ctx context.Context, req domain.UpsertStoryRequest) (domain.Story, error) {
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.Story{}, domain.ErrStoryNotFound
|
||||
}
|
||||
return s.stories.UpsertStory(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetAllStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) {
|
||||
return s.GetAllStoriesPage(ctx, viewerUserID, hidden, now, domain.StoryListCursor{}, limit)
|
||||
}
|
||||
|
||||
func (s *Service) GetAllStoriesPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) {
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return domain.StoryList{}, nil
|
||||
}
|
||||
if cursor.Set {
|
||||
if err := validateStoryListCursor(cursor); err != nil {
|
||||
return domain.StoryList{}, domain.ErrStoryOffsetInvalid
|
||||
}
|
||||
}
|
||||
return s.stories.ListActiveStoriesPage(ctx, viewerUserID, hidden, now, cursor, clampStoryLimit(limit))
|
||||
}
|
||||
|
||||
func (s *Service) GetAllStoriesDigest(ctx context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) {
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return domain.StoryListDigest{}, nil
|
||||
}
|
||||
return s.stories.ActiveStoriesDigest(ctx, viewerUserID, hidden, now)
|
||||
}
|
||||
|
||||
func (s *Service) ListOwnerActiveStories(ctx context.Context, userID int64, owner domain.Peer, now, limit int) (domain.StoryList, error) {
|
||||
if err := validateStoryOwner(userID, owner); err != nil {
|
||||
return domain.StoryList{}, err
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryList{}, nil
|
||||
}
|
||||
return s.stories.ListOwnerActiveStories(ctx, owner, now, clampStoryLimit(limit))
|
||||
}
|
||||
|
||||
func (s *Service) GetPeerStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) {
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return domain.PeerStories{Peer: peer}, nil
|
||||
}
|
||||
return s.stories.GetPeerStories(ctx, viewerUserID, peer, now)
|
||||
}
|
||||
|
||||
func (s *Service) GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) {
|
||||
ids, err := normalizeStoryIDsNonEmpty(ids)
|
||||
if err != nil {
|
||||
return domain.StoryList{}, err
|
||||
}
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return domain.StoryList{}, nil
|
||||
}
|
||||
return s.stories.GetStoriesByID(ctx, viewerUserID, peer, ids, now)
|
||||
}
|
||||
|
||||
func (s *Service) GetStoriesArchive(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
|
||||
if err := s.authorizeStoryOwner(ctx, viewerUserID, peer, storyOwnerEdit); err != nil {
|
||||
return domain.StoryList{}, err
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryList{}, nil
|
||||
}
|
||||
return s.stories.ListStoriesArchive(ctx, viewerUserID, peer, offsetID, clampStoryArchiveLimit(limit), now)
|
||||
}
|
||||
|
||||
func (s *Service) GetPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return domain.StoryList{}, nil
|
||||
}
|
||||
return s.stories.ListPinnedStories(ctx, viewerUserID, peer, offsetID, clampStoryLimit(limit), now)
|
||||
}
|
||||
|
||||
func (s *Service) HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) {
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return s.stories.HasPinnedStories(ctx, viewerUserID, peer, now)
|
||||
}
|
||||
|
||||
func (s *Service) ListReadStates(ctx context.Context, viewerUserID int64) ([]domain.StoryReadState, error) {
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.stories.ListReadStates(ctx, viewerUserID)
|
||||
}
|
||||
|
||||
func (s *Service) GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) {
|
||||
if len(peers) > domain.MaxStoryIDs {
|
||||
return nil, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
|
||||
out := make([]domain.RecentStory, len(peers))
|
||||
for i, peer := range peers {
|
||||
out[i].Peer = peer
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return s.stories.GetPeerMaxIDs(ctx, viewerUserID, peers, now)
|
||||
}
|
||||
|
||||
func (s *Service) GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) {
|
||||
if len(peers) > domain.MaxStoryIDs {
|
||||
return nil, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
|
||||
return map[domain.Peer]bool{}, nil
|
||||
}
|
||||
return s.stories.GetPeerHiddenStates(ctx, viewerUserID, peers)
|
||||
}
|
||||
|
||||
func (s *Service) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) {
|
||||
if len(peers) > domain.MaxStoryIDs {
|
||||
return nil, domain.ErrStoryIDInvalid
|
||||
}
|
||||
out := make([]domain.PeerStoryProjection, len(peers))
|
||||
for i, peer := range peers {
|
||||
out[i].Peer = peer
|
||||
out[i].Recent.Peer = peer
|
||||
}
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
return s.stories.GetPeerStoryProjections(ctx, viewerUserID, peers, now)
|
||||
}
|
||||
|
||||
func (s *Service) ReadStories(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) {
|
||||
if maxID <= 0 || maxID > domain.MaxStoryID {
|
||||
return domain.StoryReadResult{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, Date: date}, nil
|
||||
}
|
||||
recent, err := s.stories.GetPeerMaxIDs(ctx, viewerUserID, []domain.Peer{peer}, date)
|
||||
if err != nil {
|
||||
return domain.StoryReadResult{}, err
|
||||
}
|
||||
if len(recent) == 0 || recent[0].MaxID <= 0 {
|
||||
return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, Date: date}, nil
|
||||
}
|
||||
if maxID > recent[0].MaxID {
|
||||
maxID = recent[0].MaxID
|
||||
}
|
||||
return s.stories.MarkRead(ctx, viewerUserID, peer, maxID, date)
|
||||
}
|
||||
|
||||
func (s *Service) IncrementViews(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) {
|
||||
ids, err := normalizeStoryIDsNonEmpty(ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.stories.IncrementViews(ctx, viewerUserID, peer, ids, date)
|
||||
}
|
||||
|
||||
func (s *Service) SendReaction(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) {
|
||||
if storyID <= 0 || storyID > domain.MaxStoryID {
|
||||
return domain.StoryReactionResult{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return domain.StoryReactionResult{ViewerID: viewerUserID, Peer: peer, StoryID: storyID, Reaction: reaction, Date: date}, nil
|
||||
}
|
||||
return s.stories.SetReaction(ctx, viewerUserID, peer, storyID, reaction, date)
|
||||
}
|
||||
|
||||
func (s *Service) GetStoryViewsList(ctx context.Context, viewerUserID int64, req domain.StoryViewListRequest) (domain.StoryViewList, error) {
|
||||
if err := s.authorizeStoryOwner(ctx, viewerUserID, req.Owner, storyOwnerEdit); err != nil {
|
||||
return domain.StoryViewList{}, err
|
||||
}
|
||||
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
|
||||
return domain.StoryViewList{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
||||
return domain.StoryViewList{}, err
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryViewList{}, nil
|
||||
}
|
||||
req.ViewerUserID = viewerUserID
|
||||
req.Limit = clampStoryInteractionLimit(req.Limit)
|
||||
req.Query = normalizeStoryViewQuery(req.Query)
|
||||
return s.stories.ListStoryViews(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetStoryReactionsList(ctx context.Context, viewerUserID int64, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) {
|
||||
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
|
||||
return domain.StoryReactionList{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if req.Owner.Type == domain.PeerTypeChannel {
|
||||
if viewerUserID == 0 || !req.CanViewOwnerInteractions {
|
||||
return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid
|
||||
}
|
||||
} else {
|
||||
return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid
|
||||
}
|
||||
if err := domain.ValidateStoryReactionInteractionOffset(req.Offset, req.ForwardsFirst); err != nil {
|
||||
return domain.StoryReactionList{}, err
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryReactionList{}, nil
|
||||
}
|
||||
req.ViewerUserID = viewerUserID
|
||||
req.Limit = clampStoryInteractionLimit(req.Limit)
|
||||
return s.stories.ListStoryReactions(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetStoryPublicForwards(ctx context.Context, viewerUserID int64, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) {
|
||||
if err := s.authorizeStoryOwner(ctx, viewerUserID, req.Owner, storyOwnerEdit); err != nil {
|
||||
return domain.StoryPublicForwardList{}, err
|
||||
}
|
||||
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
|
||||
return domain.StoryPublicForwardList{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
||||
return domain.StoryPublicForwardList{}, err
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryPublicForwardList{}, nil
|
||||
}
|
||||
req.ViewerUserID = viewerUserID
|
||||
req.Limit = clampStoryInteractionLimit(req.Limit)
|
||||
return s.stories.ListStoryPublicForwards(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) CanViewStoryStats(ctx context.Context, userID int64, peer domain.Peer) error {
|
||||
return s.authorizeStoryOwner(ctx, userID, peer, storyOwnerEdit)
|
||||
}
|
||||
|
||||
func (s *Service) ListStoryViewerIDs(ctx context.Context, userID int64, owner domain.Peer, storyID, limit int) ([]int64, error) {
|
||||
if err := validateStoryOwner(userID, owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if storyID <= 0 || storyID > domain.MaxStoryID {
|
||||
return nil, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.stories.ListStoryViewerIDs(ctx, owner, storyID, clampStoryPrivacyFanoutLimit(limit))
|
||||
}
|
||||
|
||||
func (s *Service) EditStory(ctx context.Context, userID int64, req domain.StoryEditRequest) (domain.StoryEditResult, error) {
|
||||
if err := s.authorizeStoryOwner(ctx, userID, req.Owner, storyOwnerEdit); err != nil {
|
||||
return domain.StoryEditResult{}, err
|
||||
}
|
||||
if req.ID <= 0 || req.ID > domain.MaxStoryID {
|
||||
return domain.StoryEditResult{}, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if !req.UpdateMedia && !req.UpdateCaption && !req.UpdatePrivacy && !req.UpdateMediaAreas {
|
||||
return domain.StoryEditResult{}, domain.ErrStoryNotModified
|
||||
}
|
||||
if req.UpdateMedia && req.Media == nil {
|
||||
return domain.StoryEditResult{}, domain.ErrStoryNotFound
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryEditResult{}, domain.ErrStoryNotFound
|
||||
}
|
||||
return s.stories.EditStory(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteStories(ctx context.Context, userID int64, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) {
|
||||
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerDelete); err != nil {
|
||||
return domain.StoryMutationResult{}, err
|
||||
}
|
||||
ids, err := normalizeStoryIDsNonEmpty(ids)
|
||||
if err != nil {
|
||||
return domain.StoryMutationResult{}, err
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}, nil
|
||||
}
|
||||
return s.stories.DeleteStories(ctx, peer, ids, date)
|
||||
}
|
||||
|
||||
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) {
|
||||
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerPin); err != nil {
|
||||
return domain.StoryMutationResult{}, err
|
||||
}
|
||||
ids, err := normalizeStoryIDs(ids)
|
||||
if err != nil {
|
||||
return domain.StoryMutationResult{}, err
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}, nil
|
||||
}
|
||||
return s.stories.TogglePinned(ctx, peer, ids, pinned, date)
|
||||
}
|
||||
|
||||
func (s *Service) TogglePinnedToTop(ctx context.Context, userID int64, peer domain.Peer, ids []int) error {
|
||||
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerPin); err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := normalizeStoryPinnedToTopIDs(ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.stories == nil {
|
||||
return nil
|
||||
}
|
||||
return s.stories.TogglePinnedToTop(ctx, peer, ids)
|
||||
}
|
||||
|
||||
func (s *Service) TogglePeerStoriesHidden(ctx context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error {
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.stories.SetPeerHidden(ctx, viewerUserID, peer, hidden)
|
||||
}
|
||||
|
||||
func (s *Service) CanSendStory(ctx context.Context, viewerUserID int64, peer domain.Peer) (int, error) {
|
||||
if err := s.authorizeStoryOwner(ctx, viewerUserID, peer, storyOwnerPost); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return domain.DefaultStoryCanSendRemaining, nil
|
||||
}
|
||||
|
||||
type storyOwnerAction int
|
||||
|
||||
const (
|
||||
storyOwnerPost storyOwnerAction = iota
|
||||
storyOwnerEdit
|
||||
storyOwnerDelete
|
||||
storyOwnerPin
|
||||
)
|
||||
|
||||
func (s *Service) authorizeStoryOwner(ctx context.Context, userID int64, peer domain.Peer, action storyOwnerAction) error {
|
||||
if userID == 0 {
|
||||
return domain.ErrStoryPeerInvalid
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if peer.ID == userID {
|
||||
return nil
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if peer.ID == 0 || s == nil || s.channelAccess == nil {
|
||||
return domain.ErrStoryPeerInvalid
|
||||
}
|
||||
switch action {
|
||||
case storyOwnerPost:
|
||||
return s.channelAccess.CanPostStory(ctx, userID, peer.ID)
|
||||
case storyOwnerEdit:
|
||||
return s.channelAccess.CanEditStory(ctx, userID, peer.ID)
|
||||
case storyOwnerDelete:
|
||||
return s.channelAccess.CanDeleteStory(ctx, userID, peer.ID)
|
||||
case storyOwnerPin:
|
||||
return s.channelAccess.CanPinStory(ctx, userID, peer.ID)
|
||||
}
|
||||
}
|
||||
return domain.ErrStoryPeerInvalid
|
||||
}
|
||||
|
||||
func validateStoryIDs(ids []int) error {
|
||||
if len(ids) > domain.MaxStoryIDs {
|
||||
return domain.ErrStoryIDInvalid
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxStoryID {
|
||||
return domain.ErrStoryIDInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStoryIDsNonEmpty(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return domain.ErrStoryIDInvalid
|
||||
}
|
||||
return validateStoryIDs(ids)
|
||||
}
|
||||
|
||||
func normalizeStoryIDsNonEmpty(ids []int) ([]int, error) {
|
||||
if err := validateStoryIDsNonEmpty(ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeStoryIDsUnchecked(ids), nil
|
||||
}
|
||||
|
||||
func normalizeStoryIDs(ids []int) ([]int, error) {
|
||||
if err := validateStoryIDs(ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeStoryIDsUnchecked(ids), nil
|
||||
}
|
||||
|
||||
func normalizeStoryPinnedToTopIDs(ids []int) ([]int, error) {
|
||||
ids, err := normalizeStoryIDs(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) > domain.MaxStoryPinnedToTop {
|
||||
return nil, domain.ErrStoryIDInvalid
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func normalizeStoryIDsUnchecked(ids []int) []int {
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
out := make([]int, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateStoryOwner(userID int64, peer domain.Peer) error {
|
||||
if userID == 0 {
|
||||
return domain.ErrStoryPeerInvalid
|
||||
}
|
||||
if peer.Type == domain.PeerTypeUser && peer.ID == userID {
|
||||
return nil
|
||||
}
|
||||
return domain.ErrStoryPeerInvalid
|
||||
}
|
||||
|
||||
func validateStoryListCursor(cursor domain.StoryListCursor) error {
|
||||
if !cursor.Set {
|
||||
return nil
|
||||
}
|
||||
if cursor.Date <= 0 || cursor.Peer.ID <= 0 {
|
||||
return domain.ErrStoryOffsetInvalid
|
||||
}
|
||||
switch cursor.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
return nil
|
||||
default:
|
||||
return domain.ErrStoryOffsetInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStoryPeriod(period int) int {
|
||||
if period == 0 {
|
||||
return domain.DefaultStoryPeriod
|
||||
}
|
||||
if validStoryPeriod(period) {
|
||||
return period
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func validStoryPeriod(period int) bool {
|
||||
switch period {
|
||||
case 6 * 3600, 12 * 3600, domain.DefaultStoryPeriod, 2 * domain.DefaultStoryPeriod:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clampStoryLimit(limit int) int {
|
||||
if limit <= 0 || limit > domain.MaxStoryListLimit {
|
||||
return domain.MaxStoryListLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func clampStoryArchiveLimit(limit int) int {
|
||||
if limit == 0 {
|
||||
return 0
|
||||
}
|
||||
if limit < 0 || limit > domain.MaxStoryListLimit {
|
||||
return domain.MaxStoryListLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func clampStoryInteractionLimit(limit int) int {
|
||||
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
|
||||
return domain.MaxStoryInteractionListLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func clampStoryPrivacyFanoutLimit(limit int) int {
|
||||
if limit <= 0 || limit > domain.MaxStoryPrivacyFanoutTargets {
|
||||
return domain.MaxStoryPrivacyFanoutTargets
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func normalizeStoryViewQuery(query string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
runes := []rune(query)
|
||||
if len(runes) > domain.MaxStoryViewQueryLength {
|
||||
return string(runes[:domain.MaxStoryViewQueryLength])
|
||||
}
|
||||
return query
|
||||
}
|
||||
313
internal/app/stories/service_test.go
Normal file
313
internal/app/stories/service_test.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package stories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestReadStoriesClampsFutureMaxIDToVisibleActiveStory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStoryStore()
|
||||
service := NewService(store)
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
viewerID := int64(2001)
|
||||
for _, id := range []int{1, 2} {
|
||||
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
|
||||
Owner: owner,
|
||||
ID: id,
|
||||
Date: 100 + id,
|
||||
ExpireDate: 1000,
|
||||
Public: true,
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert story %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
read, err := service.ReadStories(ctx, viewerID, owner, 99, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("read stories: %v", err)
|
||||
}
|
||||
if !read.Advanced || read.MaxReadID != 2 {
|
||||
t.Fatalf("read = %+v, want advanced max 2", read)
|
||||
}
|
||||
|
||||
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
|
||||
Owner: owner,
|
||||
ID: 3,
|
||||
Date: 300,
|
||||
ExpireDate: 1000,
|
||||
Public: true,
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert future story: %v", err)
|
||||
}
|
||||
peerStories, err := store.GetPeerStories(ctx, viewerID, owner, 400)
|
||||
if err != nil {
|
||||
t.Fatalf("get peer stories: %v", err)
|
||||
}
|
||||
if peerStories.MaxReadID != 2 || len(peerStories.Stories) != 3 {
|
||||
t.Fatalf("peer stories = %+v, want max read 2 and three active stories", peerStories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStoriesWithoutVisibleActiveStoryDoesNotWriteFutureBoundary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStoryStore()
|
||||
service := NewService(store)
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
viewerID := int64(2001)
|
||||
|
||||
read, err := service.ReadStories(ctx, viewerID, owner, 99, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("read stories: %v", err)
|
||||
}
|
||||
if read.Advanced || read.MaxReadID != 0 {
|
||||
t.Fatalf("read = %+v, want no-op zero boundary", read)
|
||||
}
|
||||
states, err := store.ListReadStates(ctx, viewerID)
|
||||
if err != nil {
|
||||
t.Fatalf("list read states: %v", err)
|
||||
}
|
||||
if len(states) != 0 {
|
||||
t.Fatalf("read states = %+v, want none", states)
|
||||
}
|
||||
|
||||
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
|
||||
Owner: owner,
|
||||
ID: 1,
|
||||
Date: 300,
|
||||
ExpireDate: 1000,
|
||||
Public: true,
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert story after no-op read: %v", err)
|
||||
}
|
||||
peerStories, err := store.GetPeerStories(ctx, viewerID, owner, 400)
|
||||
if err != nil {
|
||||
t.Fatalf("get peer stories: %v", err)
|
||||
}
|
||||
if peerStories.MaxReadID != 0 || len(peerStories.Stories) != 1 {
|
||||
t.Fatalf("peer stories = %+v, want unread first story", peerStories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStoriesWithoutStoreIsNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(nil)
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
|
||||
read, err := service.ReadStories(ctx, 2001, owner, 99, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("read stories without store: %v", err)
|
||||
}
|
||||
if read.Advanced || read.MaxReadID != 0 || read.Peer != owner || read.ViewerID != 2001 {
|
||||
t.Fatalf("read without store = %+v, want no-op zero boundary for viewer/peer", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStoryReactionsListRequiresChannelInteractionGrant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStoryStore()
|
||||
service := NewService(store)
|
||||
owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}
|
||||
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
|
||||
Owner: owner,
|
||||
ID: 1,
|
||||
Date: 100,
|
||||
ExpireDate: 1000,
|
||||
Public: true,
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert story: %v", err)
|
||||
}
|
||||
if _, err := store.SetReaction(ctx, 2001, owner, 1, &domain.MessageReaction{
|
||||
Type: domain.MessageReactionEmoji,
|
||||
Emoticon: "🔥",
|
||||
}, 110); err != nil {
|
||||
t.Fatalf("set reaction: %v", err)
|
||||
}
|
||||
|
||||
_, err := service.GetStoryReactionsList(ctx, 3001, domain.StoryReactionListRequest{
|
||||
Owner: owner,
|
||||
StoryID: 1,
|
||||
Limit: 10,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrStoryPeerInvalid) {
|
||||
t.Fatalf("ungranted channel reaction list err = %v, want ErrStoryPeerInvalid", err)
|
||||
}
|
||||
|
||||
list, err := service.GetStoryReactionsList(ctx, 3001, domain.StoryReactionListRequest{
|
||||
Owner: owner,
|
||||
StoryID: 1,
|
||||
Limit: 10,
|
||||
CanViewOwnerInteractions: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("granted channel reaction list: %v", err)
|
||||
}
|
||||
if list.Count != 1 || len(list.Reactions) != 1 || list.Reactions[0].ViewerID != 2001 {
|
||||
t.Fatalf("reaction list = %+v, want viewer 2001", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStoryReactionsListRejectsUserPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewStoryStore())
|
||||
_, err := service.GetStoryReactionsList(ctx, 1001, domain.StoryReactionListRequest{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
|
||||
StoryID: 1,
|
||||
Limit: 10,
|
||||
CanViewOwnerInteractions: true,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrStoryPeerInvalid) {
|
||||
t.Fatalf("user story reaction list err = %v, want ErrStoryPeerInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoryInteractionListsRejectMalformedOffsets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(nil)
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
if _, err := service.GetStoryViewsList(ctx, owner.ID, domain.StoryViewListRequest{
|
||||
Owner: owner,
|
||||
StoryID: 1,
|
||||
Offset: "bad",
|
||||
Limit: 10,
|
||||
}); !errors.Is(err, domain.ErrStoryOffsetInvalid) {
|
||||
t.Fatalf("bad views offset err = %v, want ErrStoryOffsetInvalid", err)
|
||||
}
|
||||
if _, err := service.GetStoryViewsList(ctx, owner.ID, domain.StoryViewListRequest{
|
||||
Owner: owner,
|
||||
StoryID: 1,
|
||||
Offset: strings.Repeat("9", domain.MaxStoryInteractionOffsetLength+1),
|
||||
Limit: 10,
|
||||
}); !errors.Is(err, domain.ErrStoryOffsetInvalid) {
|
||||
t.Fatalf("oversized views offset err = %v, want ErrStoryOffsetInvalid", err)
|
||||
}
|
||||
if _, err := service.GetStoryReactionsList(ctx, 2001, domain.StoryReactionListRequest{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
|
||||
StoryID: 1,
|
||||
Offset: "1:1700000000:2001",
|
||||
Limit: 10,
|
||||
CanViewOwnerInteractions: true,
|
||||
}); !errors.Is(err, domain.ErrStoryOffsetInvalid) {
|
||||
t.Fatalf("reaction group offset err = %v, want ErrStoryOffsetInvalid", err)
|
||||
}
|
||||
if _, err := service.GetStoryReactionsList(ctx, 2001, domain.StoryReactionListRequest{
|
||||
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001},
|
||||
StoryID: 1,
|
||||
Offset: "1:1700000000:2001",
|
||||
Limit: 10,
|
||||
ForwardsFirst: true,
|
||||
CanViewOwnerInteractions: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("reaction forwards-first group offset err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStoriesByIDDedupesAndRejectsEmptyIDs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStoryStore()
|
||||
service := NewService(store)
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
|
||||
Owner: owner,
|
||||
ID: 3,
|
||||
Date: 100,
|
||||
ExpireDate: 1000,
|
||||
Public: true,
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert story: %v", err)
|
||||
}
|
||||
|
||||
list, err := service.GetStoriesByID(ctx, owner.ID, owner, []int{3, 3}, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("get stories by duplicate ids: %v", err)
|
||||
}
|
||||
if len(list.Stories) != 1 || list.Stories[0].ID != 3 {
|
||||
t.Fatalf("stories = %+v, want one deduped story", list.Stories)
|
||||
}
|
||||
|
||||
if _, err := service.GetStoriesByID(ctx, owner.ID, owner, nil, 200); !errors.Is(err, domain.ErrStoryIDInvalid) {
|
||||
t.Fatalf("empty get stories by id err = %v, want ErrStoryIDInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementViewsDedupeAndRejectsEmptyIDs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := memory.NewStoryStore()
|
||||
service := NewService(store)
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
|
||||
Owner: owner,
|
||||
ID: 7,
|
||||
Date: 100,
|
||||
ExpireDate: 1000,
|
||||
Public: true,
|
||||
}}); err != nil {
|
||||
t.Fatalf("upsert story: %v", err)
|
||||
}
|
||||
|
||||
created, err := service.IncrementViews(ctx, 2001, owner, []int{7, 7}, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("increment views duplicate ids: %v", err)
|
||||
}
|
||||
if created != 1 {
|
||||
t.Fatalf("created views = %d, want one deduped view", created)
|
||||
}
|
||||
|
||||
if _, err := service.IncrementViews(ctx, 2001, owner, nil, 200); !errors.Is(err, domain.ErrStoryIDInvalid) {
|
||||
t.Fatalf("empty increment views err = %v, want ErrStoryIDInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoryMutationsDedupeIDsBeforeStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(nil)
|
||||
owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
|
||||
|
||||
pinned, err := service.TogglePinned(ctx, owner.ID, owner, []int{3, 3, 4}, true, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("toggle pinned: %v", err)
|
||||
}
|
||||
if got, want := pinned.IDs, []int{3, 4}; !intSlicesEqual(got, want) {
|
||||
t.Fatalf("pinned ids = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
emptyPinned, err := service.TogglePinned(ctx, owner.ID, owner, nil, true, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("empty toggle pinned: %v", err)
|
||||
}
|
||||
if len(emptyPinned.IDs) != 0 || len(emptyPinned.Stories) != 0 {
|
||||
t.Fatalf("empty pinned = %+v, want no-op", emptyPinned)
|
||||
}
|
||||
|
||||
deleted, err := service.DeleteStories(ctx, owner.ID, owner, []int{4, 3, 4}, 101)
|
||||
if err != nil {
|
||||
t.Fatalf("delete stories: %v", err)
|
||||
}
|
||||
if got, want := deleted.IDs, []int{4, 3}; !intSlicesEqual(got, want) {
|
||||
t.Fatalf("deleted ids = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if err := service.TogglePinnedToTop(ctx, owner.ID, owner, []int{4, 3, 4}); err != nil {
|
||||
t.Fatalf("toggle pinned to top deduped ids: %v", err)
|
||||
}
|
||||
if err := service.TogglePinnedToTop(ctx, owner.ID, owner, []int{1, 2, 3, 4}); !errors.Is(err, domain.ErrStoryIDInvalid) {
|
||||
t.Fatalf("oversized pinned-to-top err = %v, want ErrStoryIDInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func intSlicesEqual(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue