From 7a730cd42e10b6059a1ef878d452ec6f2e1c6077 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Sat, 29 Aug 2026 16:26:38 +0530 Subject: [PATCH] fix: chat accepts posts --- internal/handler/chat.go | 19 +- internal/model/chat.go | 15 ++ internal/repository/chat_repo.go | 28 ++- internal/repository/post_repo.go | 40 +++++ internal/repository/social_graph_repo.go | 29 +++ internal/server/server.go | 2 +- internal/service/chat_service.go | 216 ++++++++++++++++++++-- internal/service/chat_service_test.go | 218 ++++++++++++++++++++++- 8 files changed, 545 insertions(+), 22 deletions(-) diff --git a/internal/handler/chat.go b/internal/handler/chat.go index e79516c..80edbb1 100644 --- a/internal/handler/chat.go +++ b/internal/handler/chat.go @@ -36,6 +36,8 @@ func writeChatError(w http.ResponseWriter, err error) { response.Error(w, http.StatusNotFound, "conversation not found") case errors.Is(err, service.ErrAvatarUnset): response.Error(w, http.StatusNotFound, err.Error()) + case errors.Is(err, service.ErrChatBadEntity): + response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatBadQuote): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatBadAvatarKey): @@ -190,8 +192,11 @@ type sendReq struct { Attachments []mediaReq `json:"attachments"` // quote is a passage shared from the library. `path` must be app-relative; // the service refuses anything that could navigate off-site. - Quote *quoteReq `json:"quote"` - ReplyToID *string `json:"reply_to_id"` + Quote *quoteReq `json:"quote"` + // entity shares something else in the app — a post — as a card. Only the + // reference; the BFF resolves the contents and re-checks visibility. + Entity *entityReq `json:"entity"` + ReplyToID *string `json:"reply_to_id"` // forward_from_id is deliberately NOT accepted yet: forwarding lands with // its own "can the caller see the source?" check in a later phase, and // accepting an unvalidated message reference in the meantime would let a @@ -221,12 +226,17 @@ func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) { Path: req.Quote.Path, Title: req.Quote.Title, Text: req.Quote.Text, } } + var entity *service.EntityAttachment + if req.Entity != nil { + entity = &service.EntityAttachment{Type: req.Entity.Type, ID: req.Entity.ID} + } msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{ Body: req.Body, ClientMsgID: req.ClientMsgID, ReplyToID: req.ReplyToID, PreviewURL: req.PreviewURL, Quote: quote, + Entity: entity, Attachments: media, }) if err != nil { @@ -566,6 +576,11 @@ func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) { response.NoContent(w) } +type entityReq struct { + Type string `json:"type"` + ID string `json:"id"` +} + type quoteReq struct { Path string `json:"path"` Title string `json:"title"` diff --git a/internal/model/chat.go b/internal/model/chat.go index 75ff9b8..935d2d6 100644 --- a/internal/model/chat.go +++ b/internal/model/chat.go @@ -173,6 +173,11 @@ const ( AttachmentQuote = "quote" ) +// Entity types a message can reference. +const ( + EntityPost = "post" +) + // Attachment is a media file, link preview or shared entity on a message. // // Media carries an R2 key rather than a URL: chat media lives in the private @@ -201,6 +206,16 @@ type Attachment struct { SiteName *string `json:"site_name,omitempty"` ImageURL *string `json:"image_url,omitempty"` + // Shared entity (a post, for now). EntityType/EntityID are the durable + // reference; Entity is resolved per viewer at read time, so the same row + // renders differently — or not at all — depending on who is looking. + EntityType *string `json:"entity_type,omitempty"` + EntityID *string `json:"entity_id,omitempty"` + Entity json.RawMessage `json:"entity,omitempty"` + // EntityUnavailable is set when the entity was deleted, hidden, or the + // viewer has since lost access. The client renders a tombstone. + EntityUnavailable bool `json:"entity_unavailable,omitempty"` + CreatedAt time.Time `json:"created_at"` } diff --git a/internal/repository/chat_repo.go b/internal/repository/chat_repo.go index 513646e..6160db5 100644 --- a/internal/repository/chat_repo.go +++ b/internal/repository/chat_repo.go @@ -547,6 +547,12 @@ type AddAttachmentParams struct { Description *string SiteName *string ImageURL *string + + // Shared entity: the durable reference plus a snapshot used only as a + // fallback. Live data is re-resolved per viewer at read time. + EntityType *string + EntityID *string + Snapshot []byte } // AddAttachment persists one attachment. conversation_id is denormalized here @@ -557,17 +563,20 @@ func (r *ChatRepo) AddAttachment(ctx context.Context, p AddAttachmentParams) (*m INSERT INTO message_attachments (message_id, conversation_id, ord, kind, r2_key, mime, size_bytes, width, height, duration_ms, blurhash, file_name, - url, title, description, site_name, image_url) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + url, title, description, site_name, image_url, + entity_type, entity_id, snapshot) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) RETURNING id, message_id, ord, kind, mime, size_bytes, width, height, duration_ms, blurhash, file_name, url, title, description, - site_name, image_url, created_at`, + site_name, image_url, entity_type, entity_id, snapshot, created_at`, p.MessageID, p.ConversationID, p.Ord, p.Kind, p.R2Key, p.Mime, p.SizeBytes, p.Width, p.Height, p.DurationMs, p.Blurhash, p.FileName, p.URL, p.Title, p.Description, p.SiteName, p.ImageURL, + p.EntityType, p.EntityID, p.Snapshot, ).Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, &a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, &a.Title, - &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt) + &a.Description, &a.SiteName, &a.ImageURL, + &a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt) if err != nil { return nil, fmt.Errorf("add attachment: %w", err) } @@ -583,7 +592,7 @@ func (r *ChatRepo) AttachmentsFor(ctx context.Context, messageIDs []string) (map rows, err := r.pg.Pool.Query(ctx, ` SELECT id, message_id, ord, kind, mime, size_bytes, width, height, duration_ms, blurhash, file_name, url, title, description, - site_name, image_url, created_at + site_name, image_url, entity_type, entity_id, snapshot, created_at FROM message_attachments WHERE message_id = ANY($1) ORDER BY message_id, ord`, messageIDs) @@ -595,7 +604,8 @@ func (r *ChatRepo) AttachmentsFor(ctx context.Context, messageIDs []string) (map var a model.Attachment if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, &a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, - &a.Title, &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt); err != nil { + &a.Title, &a.Description, &a.SiteName, &a.ImageURL, + &a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt); err != nil { return nil, fmt.Errorf("scan attachment: %w", err) } out[a.MessageID] = append(out[a.MessageID], a) @@ -640,7 +650,8 @@ func (r *ChatRepo) Gallery(ctx context.Context, conversationID, userID string, k rows, err := r.pg.Pool.Query(ctx, ` SELECT a.id, a.message_id, a.ord, a.kind, a.mime, a.size_bytes, a.width, a.height, a.duration_ms, a.blurhash, a.file_name, a.url, a.title, - a.description, a.site_name, a.image_url, a.created_at + a.description, a.site_name, a.image_url, + a.entity_type, a.entity_id, a.snapshot, a.created_at FROM message_attachments a JOIN messages m ON m.id = a.message_id WHERE a.conversation_id = $1 @@ -661,7 +672,8 @@ func (r *ChatRepo) Gallery(ctx context.Context, conversationID, userID string, k var a model.Attachment if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, &a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, - &a.Title, &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt); err != nil { + &a.Title, &a.Description, &a.SiteName, &a.ImageURL, + &a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt); err != nil { return nil, fmt.Errorf("scan gallery row: %w", err) } out = append(out, a) diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 30af635..9af0ad7 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -160,6 +160,46 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) return p, nil } +// FindByIDs loads many posts at once, keyed by id. Missing and soft-deleted +// ids are simply absent from the map. +// +// Exists so a page of chat messages sharing N posts costs one query instead of +// N — the same reason ImagesFor is batched. +func (r *PostRepo) FindByIDs(ctx context.Context, ids []string) (map[string]*model.Post, error) { + out := make(map[string]*model.Post, len(ids)) + if len(ids) == 0 { + return out, nil + } + q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = ANY($1) AND p.deleted_at IS NULL` + rows, err := r.pg.Pool.Query(ctx, q, ids) + if err != nil { + return nil, fmt.Errorf("find posts: %w", err) + } + defer rows.Close() + + found := make([]string, 0, len(ids)) + for rows.Next() { + p := &model.Post{} + if err := scanPost(rows, p); err != nil { + return nil, fmt.Errorf("scan post: %w", err) + } + out[p.ID] = p + found = append(found, p.ID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("find posts: %w", err) + } + + imgs, err := r.ImagesFor(ctx, found) + if err != nil { + return nil, err + } + for id, p := range out { + p.Images = imgs[id] + } + return out, nil +} + // ListByHandle returns the profile feed for a user. Unknown handle → // empty page (matches pre-existing behaviour). func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) { diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go index 9942383..b12fbca 100644 --- a/internal/repository/social_graph_repo.go +++ b/internal/repository/social_graph_repo.go @@ -242,6 +242,35 @@ func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) er // BlockState reports, in one round-trip, whether a blocked b and whether // b blocked a. +// BlockedWith returns the subset of others that viewerID is blocked with in +// EITHER direction — the set whose content must be hidden from them. +// +// The batched counterpart to BlockState, for resolving many authors at once. +func (r *BlockRepo) BlockedWith(ctx context.Context, viewerID string, others []string) (map[string]bool, error) { + out := make(map[string]bool, len(others)) + if viewerID == "" || len(others) == 0 { + return out, nil + } + rows, err := r.pg.Pool.Query(ctx, ` + SELECT CASE WHEN blocker_id = $1 THEN blocked_id ELSE blocker_id END + FROM user_blocks + WHERE (blocker_id = $1 AND blocked_id = ANY($2)) + OR (blocked_id = $1 AND blocker_id = ANY($2))`, + viewerID, others) + if err != nil { + return nil, fmt.Errorf("blocked with: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan blocked with: %w", err) + } + out[id] = true + } + return out, rows.Err() +} + func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) { err = r.pg.Pool.QueryRow(ctx, ` SELECT diff --git a/internal/server/server.go b/internal/server/server.go index a5db798..04a0bf1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -261,7 +261,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { ) // Chat — DMs, groups and the (still coming-soon) Kautilya threads. chatRepo := repository.NewChatRepo(s.pg) - chatSvc := service.NewChatService(chatRepo, blockRepo, rtClient, r2, s.redis, logger) + chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo, rtClient, r2, s.redis, logger) s.chatH = handler.NewChatHandler(chatSvc) // Channel authorization for private-* subscriptions. Rules are explicit diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index c002cf9..8939fd0 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -43,6 +43,7 @@ var ( ErrChatNotAccepting = errors.New("this person isn't accepting messages from you") ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction") ErrChatBadQuote = errors.New("that quote is not a valid passage reference") + ErrChatBadEntity = errors.New("that post cannot be shared") ErrChatBadAccountAge = errors.New("account age must be between 1 and 120 months") ErrChatGenderUnset = errors.New("add your gender in profile settings to use this option") ) @@ -62,6 +63,9 @@ const ( type ChatService struct { chat *repository.ChatRepo blocks *repository.BlockRepo + // posts resolves shared post cards. Optional: nil leaves entity + // attachments unresolved rather than failing a whole thread load. + posts *repository.PostRepo rt *realtime.SoketiClient r2 *repository.R2 links *unfurl.Fetcher @@ -72,13 +76,14 @@ type ChatService struct { func NewChatService( chat *repository.ChatRepo, blocks *repository.BlockRepo, + posts *repository.PostRepo, rt *realtime.SoketiClient, r2 *repository.R2, cache *repository.Redis, logger *zap.Logger, ) *ChatService { return &ChatService{ - chat: chat, blocks: blocks, rt: rt, r2: r2, + chat: chat, blocks: blocks, posts: posts, rt: rt, r2: r2, links: unfurl.New(), cache: cache, logger: logger, } } @@ -317,11 +322,12 @@ func (s *ChatService) Starred(ctx context.Context, userID, conversationID string if err != nil { return nil, err } - return s.hydrateAttachments(ctx, msgs) + return s.hydrateAttachments(ctx, userID, msgs) } -// hydrateAttachments fills in each message's attachments in one query. -func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Message) ([]model.Message, error) { +// hydrateAttachments fills in each message's attachments in one query, then +// resolves any shared entities for this particular viewer. +func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, msgs []model.Message) ([]model.Message, error) { ids := make([]string, 0, len(msgs)) for i := range msgs { ids = append(ids, msgs[i].ID) @@ -338,6 +344,11 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Messa msgs[i].Attachments = []model.Attachment{} } } + pages := make([][]model.Attachment, 0, len(msgs)) + for i := range msgs { + pages = append(pages, msgs[i].Attachments) + } + s.resolveEntities(ctx, viewerID, pages) return msgs, nil } @@ -364,7 +375,7 @@ func (s *ChatService) Messages(ctx context.Context, userID, conversationID strin return nil, err } - return s.hydrateAttachments(ctx, msgs) + return s.hydrateAttachments(ctx, userID, msgs) } // SendRequest is one outgoing message from a client. @@ -379,6 +390,8 @@ type SendRequest struct { // fetches the title, description and image itself, because a client that // could supply them could make any link claim to be anything. PreviewURL *string + // Entity is something else in the app — a post — shared as a card. + Entity *EntityAttachment // Quote is a passage shared from the library, rendered as a quote the // reader can tap to return to the exact phrase. Quote *QuoteAttachment @@ -419,7 +432,7 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r body := strings.TrimSpace(req.Body) // A photo or a shared passage with no caption is an ordinary message; only // a message with nothing at all in it is empty. - if body == "" && len(req.Attachments) == 0 && req.Quote == nil { + if body == "" && len(req.Attachments) == 0 && req.Quote == nil && req.Entity == nil { return nil, ErrChatEmptyBody } if len([]rune(body)) > maxBodyRunes { @@ -451,14 +464,17 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r // the first time, and re-publishing would duplicate the bubble for // everyone else. if !created { - return s.withAttachments(ctx, msg) + return s.withAttachments(ctx, userID, msg) } + if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil { + s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err)) + } s.attachQuote(ctx, conversationID, msg, req.Quote) s.attachMedia(ctx, conversationID, userID, msg, req.Attachments) s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL) - full, err := s.withAttachments(ctx, msg) + full, err := s.withAttachments(ctx, userID, msg) if err != nil { full = msg } @@ -467,7 +483,7 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r } // withAttachments hydrates a message's attachments for the response. -func (s *ChatService) withAttachments(ctx context.Context, msg *model.Message) (*model.Message, error) { +func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg *model.Message) (*model.Message, error) { byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID}) if err != nil { return msg, err @@ -476,6 +492,7 @@ func (s *ChatService) withAttachments(ctx context.Context, msg *model.Message) ( if msg.Attachments == nil { msg.Attachments = []model.Attachment{} } + s.resolveEntities(ctx, viewerID, [][]model.Attachment{msg.Attachments}) return msg, nil } @@ -509,6 +526,185 @@ func (s *ChatService) attachMedia(ctx context.Context, conversationID, userID st } } +// EntityAttachment is a reference to something else in the app, shared into a +// chat. Only posts for now; the column allows comments, profiles and +// collections when their cards are built. +type EntityAttachment struct { + Type string + ID string +} + +// postSnapshot is the copy stored alongside the reference. +// +// It is a FALLBACK, never the source of truth: resolveEntities re-reads the +// live post for each viewer, because a post can be deleted, hidden, or become +// invisible to one participant after it was shared. Rendering the snapshot +// unconditionally would leak a post to someone who has since lost access. +type postSnapshot struct { + ID string `json:"id"` + AuthorHandle string `json:"author_handle"` + AuthorName string `json:"author_name"` + AuthorAvatarURL *string `json:"author_avatar_url,omitempty"` + Caption string `json:"caption"` + ImageURL *string `json:"image_url,omitempty"` + SourceTitle string `json:"source_title,omitempty"` +} + +func snapshotOf(p *model.Post) postSnapshot { + snap := postSnapshot{ + ID: p.ID, AuthorHandle: p.AuthorHandle, AuthorName: p.AuthorName, + AuthorAvatarURL: p.AuthorAvatarURL, Caption: p.Caption, + SourceTitle: p.SourceTitle, + } + if len(p.Images) > 0 { + url := p.Images[0].URL + snap.ImageURL = &url + } + return snap +} + +// postVisibleTo decides whether a viewer may see a post inside a chat bubble. +// +// Same rules the feed applies: a hidden post is the owner's alone, and a block +// in either direction hides the author's content. +func (s *ChatService) postVisibleTo(ctx context.Context, viewerID string, p *model.Post) bool { + if p.IsHidden && p.UserID != viewerID { + return false + } + if p.UserID == viewerID { + return true + } + blocked, blockedBy, err := s.blocks.BlockState(ctx, viewerID, p.UserID) + if err != nil { + // Fail closed: an unreadable block table must not open a post up. + return false + } + return !blocked && !blockedBy +} + +// attachEntity stores a shared post after checking the SENDER can see it — +// you cannot forward what you were never allowed to read. +func (s *ChatService) attachEntity(ctx context.Context, conversationID, userID string, msg *model.Message, e *EntityAttachment) error { + if e == nil { + return nil + } + if e.Type != model.EntityPost { + return ErrChatBadEntity + } + post, err := s.posts.FindByID(ctx, e.ID) + if err != nil || post == nil { + return ErrChatBadEntity + } + if !s.postVisibleTo(ctx, userID, post) { + return ErrChatBadEntity + } + + snap, err := json.Marshal(snapshotOf(post)) + if err != nil { + return ErrChatBadEntity + } + entityType, entityID := model.EntityPost, post.ID + if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{ + MessageID: msg.ID, ConversationID: conversationID, Ord: 0, + Kind: model.AttachmentEntity, EntityType: &entityType, + EntityID: &entityID, Snapshot: snap, + }); err != nil { + s.logger.Warn("chat: attach entity failed", zap.Error(err)) + } + return nil +} + +// resolveEntities re-reads every shared entity on a page for THIS viewer. +// +// This is the permission re-check that makes sharing safe: access is decided +// at read time, per person, not frozen at send time. A post that was visible +// when shared and is hidden now renders as unavailable. +// +// It takes the whole page at once — three queries (posts, their images, +// blocks) regardless of how many shares are on it. Resolving per message +// instead would cost two queries per share and could not dedupe a post quoted +// in several messages. +func (s *ChatService) resolveEntities(ctx context.Context, viewerID string, pages [][]model.Attachment) { + if s.posts == nil { + return + } + + // Gather the distinct posts referenced anywhere on the page. + shared := map[string]bool{} + for _, atts := range pages { + for i := range atts { + if atts[i].Kind == model.AttachmentEntity && atts[i].EntityID != nil { + shared[*atts[i].EntityID] = true + } + } + } + if len(shared) == 0 { + return + } + ids := make([]string, 0, len(shared)) + for id := range shared { + ids = append(ids, id) + } + + posts, err := s.posts.FindByIDs(ctx, ids) + if err != nil { + // Fail closed: every card becomes a tombstone rather than falling back + // to snapshots whose visibility we could not confirm. + s.logger.Warn("chat: resolving shared posts failed", zap.Error(err)) + posts = nil + } + + authors := make([]string, 0, len(posts)) + for _, post := range posts { + if post.UserID != viewerID { + authors = append(authors, post.UserID) + } + } + blocked, err := s.blocks.BlockedWith(ctx, viewerID, authors) + if err != nil { + s.logger.Warn("chat: block lookup for shared posts failed", zap.Error(err)) + // Fail closed again: an unreadable block table must not open posts up. + blocked = nil + for _, id := range authors { + if blocked == nil { + blocked = map[string]bool{} + } + blocked[id] = true + } + } + + // Resolve each referenced post once, then fan the result out. + resolved := make(map[string]json.RawMessage, len(shared)) + for id := range shared { + post := posts[id] + if post == nil { + continue + } + if post.IsHidden && post.UserID != viewerID { + continue + } + if blocked[post.UserID] { + continue + } + if b, mErr := json.Marshal(snapshotOf(post)); mErr == nil { + resolved[id] = b + } + } + + for _, atts := range pages { + for i := range atts { + a := &atts[i] + if a.Kind != model.AttachmentEntity || a.EntityID == nil { + continue + } + // The stored snapshot is never sent to a client — only a copy + // resolved for this viewer — so a stale one cannot leak. + a.Entity = resolved[*a.EntityID] + a.EntityUnavailable = a.Entity == nil + } + } +} + // QuoteAttachment is a passage shared out of the library. type QuoteAttachment struct { // Path is an APP-RELATIVE deep link, e.g. "/books/bg/2/13?hl=…". @@ -823,7 +1019,7 @@ func (s *ChatService) ForwardMessage(ctx context.Context, userID, messageID, toC return nil, err } if created { - full, hErr := s.withAttachments(ctx, msg) + full, hErr := s.withAttachments(ctx, userID, msg) if hErr == nil { msg = full } diff --git a/internal/service/chat_service_test.go b/internal/service/chat_service_test.go index 9263855..67a43c4 100644 --- a/internal/service/chat_service_test.go +++ b/internal/service/chat_service_test.go @@ -8,10 +8,13 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/joho/godotenv" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" @@ -40,7 +43,7 @@ func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *reposit // Nil realtime/R2/cache clients make publishing, uploads and caching // no-ops — these tests are about the rules, and none of those services are // reachable from a test run. - return NewChatService(chat, repository.NewBlockRepo(pg), nil, nil, nil, zap.NewNop()), chat, pg + return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg), nil, nil, nil, zap.NewNop()), chat, pg } // chatUserWithHandle returns both identifiers: ids for authoring, handles for @@ -1766,3 +1769,216 @@ func TestQuoteAttachment_RejectsOffSitePaths(t *testing.T) { t.Error("valid() = true for an oversized passage") } } + +// makePost inserts a post owned by userID and returns its id. +func makePost(t *testing.T, pg *repository.Postgres, userID, caption string) string { + t.Helper() + var id string + if err := pg.Pool.QueryRow(context.Background(), ` + INSERT INTO posts (user_id, caption, source_url, source_title) + VALUES ($1, $2, '/books/bg/2/13', 'Bhagavad-gītā 2.13') RETURNING id`, + userID, caption).Scan(&id); err != nil { + t.Fatalf("create post: %v", err) + } + t.Cleanup(func() { + _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM posts WHERE id = $1`, id) + }) + return id +} + +func entityAttachment(t *testing.T, msgs []model.Message) *model.Attachment { + t.Helper() + for i := range msgs { + for j := range msgs[i].Attachments { + if msgs[i].Attachments[j].Kind == model.AttachmentEntity { + return &msgs[i].Attachments[j] + } + } + } + return nil +} + +// Sharing is a reference, not a copy: access is decided when the message is +// READ, per person. A post that stops being visible must stop rendering, even +// though the snapshot taken at send time still sits in the row. +func TestSharedPost_ResolvedPerViewerAtReadTime(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + postID := makePost(t, pg, a, "a passage worth sharing") + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{ + Entity: &EntityAttachment{Type: model.EntityPost, ID: postID}, + }); err != nil { + t.Fatalf("share post: %v", err) + } + + // Recipient sees the card. + msgs, err := svc.Messages(ctx, b, conv.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + att := entityAttachment(t, msgs) + if att == nil { + t.Fatal("no entity attachment on the shared message") + } + if att.EntityUnavailable || len(att.Entity) == 0 { + t.Fatalf("recipient should see the card: unavailable=%v entity=%s", + att.EntityUnavailable, att.Entity) + } + + // Author hides the post → it must stop resolving for the recipient... + if _, err := pg.Pool.Exec(ctx, `UPDATE posts SET is_hidden = true WHERE id = $1`, postID); err != nil { + t.Fatal(err) + } + msgs, err = svc.Messages(ctx, b, conv.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + att = entityAttachment(t, msgs) + if att == nil || !att.EntityUnavailable { + t.Error("a hidden post must render as unavailable to the recipient") + } + if att != nil && len(att.Entity) != 0 { + t.Errorf("stale snapshot leaked after the post was hidden: %s", att.Entity) + } + + // ...while its owner still sees it, matching the feed's rules. + msgs, err = svc.Messages(ctx, a, conv.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + att = entityAttachment(t, msgs) + if att == nil || att.EntityUnavailable { + t.Error("the owner should still see their own hidden post") + } +} + +// You cannot share what you were never allowed to read. +func TestSharedPost_SenderMustSeeItFirst(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, stranger := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + hidden := makePost(t, pg, stranger, "not yours to share") + if _, err := pg.Pool.Exec(ctx, `UPDATE posts SET is_hidden = true WHERE id = $1`, hidden); err != nil { + t.Fatal(err) + } + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ + Body: "look at this", + Entity: &EntityAttachment{Type: model.EntityPost, ID: hidden}, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + full, err := svc.Messages(ctx, a, conv.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + if att := entityAttachment(t, full); att != nil { + t.Errorf("a post the sender cannot see must not be attached (msg %s)", msg.ID) + } +} + +// queryCounter counts every statement the pool issues. +type queryCounter struct{ n atomic.Int64 } + +func (q *queryCounter) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context { + q.n.Add(1) + return ctx +} +func (q *queryCounter) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {} + +// Resolving shared posts must cost the same whether a page carries one share +// or many — otherwise a thread full of shared posts turns one screen into +// dozens of round-trips. +func TestSharedPosts_ResolutionDoesNotScaleWithShareCount(t *testing.T) { + uri := os.Getenv("POSTGRES_URI") + if uri == "" { + _ = godotenv.Load(filepath.Join("..", "..", ".env")) + uri = os.Getenv("POSTGRES_URI") + } + if uri == "" { + t.Skip("POSTGRES_URI not set") + } + ctx := context.Background() + + cfg, err := pgxpool.ParseConfig(uri) + if err != nil { + t.Fatal(err) + } + counter := &queryCounter{} + cfg.ConnConfig.Tracer = counter + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Skipf("postgres unreachable: %v", err) + } + t.Cleanup(pool.Close) + + pg := &repository.Postgres{Pool: pool} + svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg), + repository.NewPostRepo(pg), nil, nil, nil, zap.NewNop()) + + // measure returns the statement count for reading a thread carrying + // `shares` distinct shared posts. + measure := func(shares int) int { + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + for i := 0; i < shares; i++ { + id := makePost(t, pg, a, "shared") + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{ + Entity: &EntityAttachment{Type: model.EntityPost, ID: id}, + }); err != nil { + t.Fatal(err) + } + } + // Warm the pool so connection setup isn't counted. + if _, err := svc.Messages(ctx, b, conv.ID, 0, 50); err != nil { + t.Fatal(err) + } + + before := counter.n.Load() + msgs, err := svc.Messages(ctx, b, conv.ID, 0, 50) + if err != nil { + t.Fatal(err) + } + after := counter.n.Load() + + // Guard the measurement: the cards must actually have resolved, or a + // low count would just mean nothing was looked up. + resolved := 0 + for i := range msgs { + for j := range msgs[i].Attachments { + if msgs[i].Attachments[j].Kind == model.AttachmentEntity && + !msgs[i].Attachments[j].EntityUnavailable { + resolved++ + } + } + } + if resolved != shares { + t.Fatalf("resolved %d of %d shared posts", resolved, shares) + } + return int(after - before) + } + + one, many := measure(1), measure(8) + t.Logf("queries: 1 share = %d, 8 shares = %d", one, many) + if many != one { + t.Errorf("reading 8 shares cost %d queries vs %d for 1 — resolution is still per-share", + many, one) + } +}