package service import ( "context" "os" "path/filepath" "testing" "time" "github.com/google/uuid" "github.com/joho/godotenv" "github.com/prabhupadasays/spsays-bff/internal/repository" "go.uber.org/zap" ) func testBroadcastService(t *testing.T) (*BroadcastService, *repository.Postgres) { t.Helper() 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") } pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop()) if err != nil { t.Skipf("postgres unreachable: %v", err) } t.Cleanup(pg.Close) // Nil push/realtime/R2: these tests are about who sees what, and none of // those services are reachable from a test run. return NewBroadcastService(repository.NewBroadcastRepo(pg), nil, nil, nil, nil, nil, zap.NewNop()), pg } func bcUser(t *testing.T, pg *repository.Postgres, label string) string { t.Helper() id := uuid.NewString() suffix := id[:8] if _, err := pg.Pool.Exec(context.Background(), ` INSERT INTO users (id, email, password_hash, name, handle) VALUES ($1, $2, 'x', $3, $4)`, id, "bc-"+suffix+"@example.invalid", "BC "+label, "bc_"+suffix); err != nil { t.Fatalf("create user: %v", err) } t.Cleanup(func() { _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id) }) return id } func cleanupBroadcast(t *testing.T, pg *repository.Postgres, id string) { t.Cleanup(func() { _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM broadcasts WHERE id = $1`, id) }) } // "At least and at most once" is the whole contract. A user must be offered a // published announcement until they dismiss it, and never again after. func TestBroadcast_ShownExactlyOncePerUser(t *testing.T) { svc, pg := testBroadcastService(t) ctx := context.Background() admin, alice, bob := bcUser(t, pg, "Admin"), bcUser(t, pg, "A"), bcUser(t, pg, "B") b, err := svc.Create(ctx, admin, CreateParams{ Title: "New: chat", Body: "Direct messages are here.", }) if err != nil { t.Fatal(err) } cleanupBroadcast(t, pg, b.ID) // A draft is nobody's business yet. if got, _ := svc.Pending(ctx, alice); got != nil { t.Fatalf("an unpublished draft was offered: %+v", got) } if _, err := svc.Publish(ctx, b.ID); err != nil { t.Fatal(err) } // At least once: offered, and still offered until dismissed. for i := 0; i < 2; i++ { got, err := svc.Pending(ctx, alice) if err != nil { t.Fatal(err) } if got == nil || got.ID != b.ID { t.Fatalf("call %d: expected the published announcement, got %+v", i+1, got) } } // At most once: dismissed means gone, and dismissing twice is harmless. if err := svc.MarkSeen(ctx, b.ID, alice); err != nil { t.Fatal(err) } if err := svc.MarkSeen(ctx, b.ID, alice); err != nil { t.Errorf("second dismissal should be idempotent, got %v", err) } if got, _ := svc.Pending(ctx, alice); got != nil { t.Errorf("shown again after dismissal: %+v", got) } // One person dismissing must not dismiss it for everyone. if got, _ := svc.Pending(ctx, bob); got == nil || got.ID != b.ID { t.Errorf("another user lost the announcement: %+v", got) } } // An announcement past its expiry stops being offered, so someone signing up // months later isn't shown last quarter's news. func TestBroadcast_ExpiryStopsDelivery(t *testing.T) { svc, pg := testBroadcastService(t) ctx := context.Background() admin, reader := bcUser(t, pg, "Admin"), bcUser(t, pg, "R") past := time.Now().Add(-time.Hour) b, err := svc.Create(ctx, admin, CreateParams{ Title: "Old news", Body: "Yesterday's announcement.", ExpiresAt: &past, }) if err != nil { t.Fatal(err) } cleanupBroadcast(t, pg, b.ID) if _, err := svc.Publish(ctx, b.ID); err != nil { t.Fatal(err) } if got, _ := svc.Pending(ctx, reader); got != nil { t.Errorf("an expired announcement was offered: %+v", got) } } // Only the newest announcement is offered: two modals stacked on launch is a // worse experience than missing the older one. func TestBroadcast_OffersNewestFirst(t *testing.T) { svc, pg := testBroadcastService(t) ctx := context.Background() admin, reader := bcUser(t, pg, "Admin"), bcUser(t, pg, "R") older, err := svc.Create(ctx, admin, CreateParams{Title: "First", Body: "one"}) if err != nil { t.Fatal(err) } cleanupBroadcast(t, pg, older.ID) if _, err := svc.Publish(ctx, older.ID); err != nil { t.Fatal(err) } newer, err := svc.Create(ctx, admin, CreateParams{Title: "Second", Body: "two"}) if err != nil { t.Fatal(err) } cleanupBroadcast(t, pg, newer.ID) if _, err := svc.Publish(ctx, newer.ID); err != nil { t.Fatal(err) } got, err := svc.Pending(ctx, reader) if err != nil { t.Fatal(err) } if got == nil || got.ID != newer.ID { t.Errorf("expected the newest announcement, got %+v", got) } }