fix: chat accepts posts

This commit is contained in:
Krispire Admin
2026-08-29 16:26:38 +05:30
parent 18bac9a22d
commit 7a730cd42e
8 changed files with 545 additions and 22 deletions
+217 -1
View File
@@ -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)
}
}