fix: chat accepts posts
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user