diff --git a/cmd/server/consumers.go b/cmd/server/consumers.go index 324054c..2a33cf0 100644 --- a/cmd/server/consumers.go +++ b/cmd/server/consumers.go @@ -198,21 +198,6 @@ func (a *application) registerFeedConsumers() ([]feedConsumer, error) { // them: the deprecated community-repo post, the author-repo postv2, and // the acceptance/removal pair. One consumer, because they write the same // admission row and an acceptance is meaningless without the post it pins. - // - // The direct fetcher is what makes acceptance-before-post converge without - // full relay coverage (PRD §5.4). It dials a PDS named by a DID document - // anyone can publish, so its SSRF guard stays on in production and the hatch - // opens only under IS_DEV_ENV — where the hermetic stack's PDS is a private - // address the guard would otherwise refuse. - // - // The decision goes through jetstream.PrivatePostFetcherOptions rather than - // the `if` that used to stand here, for the same reason the community - // consumer's gate does nineteen lines above: `.env.ci:140` sets - // IS_DEV_ENV=true, so `make ci` takes the permissive branch and an inline - // conditional in wiring is reachable only by standing up this wiring with a - // production config, which nothing in this tree does. As a pure function the - // branch production actually runs is testable in T0. - // // The warning stays here, because it is about this process rather than about // the option — a helper that logged would fire once per test that builds a // hatched fetcher, and this line has to mean "this server is running @@ -220,23 +205,9 @@ func (a *application) registerFeedConsumers() ([]feedConsumer, error) { if a.allowPrivateHosts() { slog.Warn("direct post fetch has SSRF protection DISABLED (IS_DEV_ENV); this must never be set in production") } - postFetcher := jetstream.NewDirectPostFetcher(a.identityResolver, - jetstream.PrivatePostFetcherOptions(a.allowPrivateHosts())...) consumers = append(consumers, feedConsumer{ - name: jetstream.ConsumerPosts, - handler: jetstream.NewPostEventConsumer(a.postRepo, a.communityRepo, a.userService, a.db, - jetstream.WithPostBridgeTrust(a.bridgeTrust), - jetstream.WithPostIdentityResolver(a.identityResolver), - jetstream.WithAdmissions(a.admissionRepo), - jetstream.WithDeletedAccounts(postgresRepo.NewDeletedAccountRepository(a.db)), - jetstream.WithPostRecordFetcher(postFetcher), - jetstream.WithPostMediaReconciler(a.mediaReconciler), - // The host-side half of an author's own deletion (§5.3): when the - // author tombstones a post this instance's community accepted, the - // acceptance in that community's repo is withdrawn. It refuses - // itself for every community this AppView does not host, which on - // most instances is all of them. - jetstream.WithAcceptanceCleanup(a.communityWriter)), + name: jetstream.ConsumerPosts, + handler: a.buildPostConsumer(), }) // Aggregators: service declarations and authorization records, following @@ -255,17 +226,14 @@ func (a *application) registerFeedConsumers() ([]feedConsumer, error) { // redriver replays it until the attempt budget is spent, and the row then sits // retired in the queue alongside events that represent a real backlog. consumers = append(consumers, feedConsumer{ - name: jetstream.ConsumerVotes, - handler: jetstream.NewVoteEventConsumer(a.voteRepo, a.userService, a.db, - jetstream.WithVoteDeletedAccounts(postgresRepo.NewDeletedAccountRepository(a.db))), + name: jetstream.ConsumerVotes, + handler: a.buildVoteConsumer(), }) // Comments from user repositories, with atomic parent count updates. consumers = append(consumers, feedConsumer{ - name: jetstream.ConsumerComments, - handler: jetstream.NewCommentEventConsumer(a.commentRepo, a.db, - jetstream.WithCommentBridgeTrust(a.bridgeTrust), - jetstream.WithCommentMediaReconciler(a.mediaReconciler)), + name: jetstream.ConsumerComments, + handler: a.buildCommentConsumer(), }) return consumers, nil @@ -284,3 +252,48 @@ func warnIfNoPrimaryFeed(feeds []jetstream.Feed) { "suffixed \"@\", so cursors persisted under the bare legacy names will NOT be used", "primary_feed_key", jetstream.PrimaryFeedKey) } + +// buildVoteConsumer builds the vote consumer registered on the feed. +func (a *application) buildVoteConsumer() *jetstream.VoteEventConsumer { + return jetstream.NewVoteEventConsumer(a.voteRepo, a.userService, a.db, + jetstream.WithVoteDeletedAccounts(postgresRepo.NewDeletedAccountRepository(a.db)), + jetstream.WithVoteBridgeTrust(a.bridgeTrust), + jetstream.WithVoteNotifications(a.notificationRepo)) +} + +// buildCommentConsumer builds the comment consumer registered on the feed. +func (a *application) buildCommentConsumer() *jetstream.CommentEventConsumer { + return jetstream.NewCommentEventConsumer(a.commentRepo, a.db, + jetstream.WithCommentBridgeTrust(a.bridgeTrust), + jetstream.WithCommentMediaReconciler(a.mediaReconciler), + jetstream.WithCommentNotifications(a.notificationRepo)) +} + +// buildPostConsumer builds the post consumer registered on the feed. +func (a *application) buildPostConsumer() *jetstream.PostEventConsumer { + // The direct fetcher is what makes acceptance-before-post converge without + // full relay coverage (PRD §5.4). It dials a PDS named by a DID document + // anyone can publish, so its SSRF guard stays on in production and the hatch + // opens only under IS_DEV_ENV — where the hermetic stack's PDS is a private + // address the guard would otherwise refuse. + // + // The decision goes through jetstream.PrivatePostFetcherOptions rather than + // an inline conditional: `.env.ci:140` sets IS_DEV_ENV=true, so `make ci` + // takes the permissive branch. The production branch is testable in T0. + postFetcher := jetstream.NewDirectPostFetcher(a.identityResolver, + jetstream.PrivatePostFetcherOptions(a.allowPrivateHosts())...) + return jetstream.NewPostEventConsumer(a.postRepo, a.communityRepo, a.userService, a.db, + jetstream.WithPostBridgeTrust(a.bridgeTrust), + jetstream.WithPostIdentityResolver(a.identityResolver), + jetstream.WithAdmissions(a.admissionRepo), + jetstream.WithDeletedAccounts(postgresRepo.NewDeletedAccountRepository(a.db)), + jetstream.WithPostRecordFetcher(postFetcher), + jetstream.WithPostMediaReconciler(a.mediaReconciler), + // The host-side half of an author's own deletion (§5.3): when the + // author tombstones a post this instance's community accepted, the + // acceptance in that community's repo is withdrawn. It refuses + // itself for every community this AppView does not host, which on + // most instances is all of them. + jetstream.WithAcceptanceCleanup(a.communityWriter), + jetstream.WithPostNotifications(a.notificationRepo)) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 3d21a0e..265e71d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -115,6 +115,7 @@ func run() error { startAggregatorTokenRefreshJob(backgroundCtx, &backgroundWG, app.apiKeyService) startDiscoverHotCleanupJob(backgroundCtx, &backgroundWG, app.discoverHotStateCleaner, discoverHotCleanupInterval, discoverHotCleanupDerivedRowBatchSize) + startNotificationRetentionJob(backgroundCtx, &backgroundWG, app.notificationRetentionSweeper, notificationRetentionInterval) startModerationIdempotencySweepJob(backgroundCtx, &backgroundWG, postgresRepo.NewModerationRepository(db), moderationIdempotencySweepInterval) if app.cdnPurgeQueue != nil { diff --git a/cmd/server/notification_retention_job.go b/cmd/server/notification_retention_job.go new file mode 100644 index 0000000..1cbee55 --- /dev/null +++ b/cmd/server/notification_retention_job.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "log/slog" + "sync" + "time" + + "Coves/internal/core/notifications" +) + +const notificationRetentionInterval = time.Hour + +func startNotificationRetentionJob(ctx context.Context, waitGroup *sync.WaitGroup, sweeper notifications.RetentionSweeper, interval time.Duration) { + runTicker(ctx, waitGroup, "notification-retention", interval, func(ctx context.Context) { + for _, sweep := range []struct { + name string + run func(context.Context) (int64, error) + }{ + {"read", sweeper.SweepReadNotifications}, + {"unread_cap", sweeper.SweepUnreadOverflow}, + {"empty_groups", sweeper.SweepEmptyUpvoteGroups}, + {"hidden_references", sweeper.SweepHiddenReferenceNotifications}, + } { + if ctx.Err() != nil { + slog.Info("notification retention cycle stopped", "before_sweep", sweep.name, "reason", ctx.Err()) + return + } + var totalRemoved int64 + for { + removed, err := sweep.run(ctx) + if err != nil { + if ctx.Err() == nil { + slog.Error("notification retention sweep failed", "sweep", sweep.name, "error", err) + } + break + } + totalRemoved += removed + if removed != notifications.RetentionBatchSize || ctx.Err() != nil { + break + } + } + if totalRemoved > 0 { + slog.Info("notification retention sweep completed", "sweep", sweep.name, "removed", totalRemoved) + } + } + }) +} diff --git a/cmd/server/notification_retention_job_test.go b/cmd/server/notification_retention_job_test.go new file mode 100644 index 0000000..954a797 --- /dev/null +++ b/cmd/server/notification_retention_job_test.go @@ -0,0 +1,286 @@ +package main + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "Coves/internal/config" + "Coves/internal/core/notifications" + "Coves/internal/crypto/credentialcipher/credentialciphertest" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type notificationRetentionSweeperFake struct { + readCalls atomic.Int64 + unreadCalls atomic.Int64 + groupCalls atomic.Int64 + hiddenCalls atomic.Int64 + secondReadAfterAllSweeps atomic.Bool + readResults []int64 + unreadResults []int64 + groupResults []int64 + hiddenResults []int64 + firstReadError error + // interruptSweep names the sweep whose first call runs interrupt, which + // stands in for the cycle context ending mid-sweep. While interrupt is + // set, every other call made with a done context returns ctx.Err(), as a + // database call would. + interruptSweep string + interrupt func(ctx context.Context) (int64, error) +} + +func (f *notificationRetentionSweeperFake) interrupted(ctx context.Context, sweep string, call int64) (removed int64, handled bool, err error) { + switch { + case f.interrupt == nil: + return 0, false, nil + case sweep == f.interruptSweep && call == 1: + removed, err = f.interrupt(ctx) + return removed, true, err + case ctx.Err() != nil: + return 0, true, ctx.Err() + } + return 0, false, nil +} + +func (f *notificationRetentionSweeperFake) SweepReadNotifications(ctx context.Context) (int64, error) { + call := f.readCalls.Add(1) + if removed, handled, err := f.interrupted(ctx, "read", call); handled { + return removed, err + } + if call == 2 { + f.secondReadAfterAllSweeps.Store(f.unreadCalls.Load() > 0 && f.groupCalls.Load() > 0 && f.hiddenCalls.Load() > 0) + } + if call == 1 && f.firstReadError != nil { + return 0, f.firstReadError + } + if index := int(call - 1); index < len(f.readResults) { + return f.readResults[index], nil + } + return 0, nil +} + +func (f *notificationRetentionSweeperFake) SweepUnreadOverflow(ctx context.Context) (int64, error) { + call := f.unreadCalls.Add(1) + if removed, handled, err := f.interrupted(ctx, "unread_cap", call); handled { + return removed, err + } + if index := int(call - 1); index < len(f.unreadResults) { + return f.unreadResults[index], nil + } + return 0, nil +} + +func (f *notificationRetentionSweeperFake) SweepEmptyUpvoteGroups(ctx context.Context) (int64, error) { + call := f.groupCalls.Add(1) + if removed, handled, err := f.interrupted(ctx, "empty_groups", call); handled { + return removed, err + } + if index := int(call - 1); index < len(f.groupResults) { + return f.groupResults[index], nil + } + return 0, nil +} + +func (f *notificationRetentionSweeperFake) SweepHiddenReferenceNotifications(ctx context.Context) (int64, error) { + call := f.hiddenCalls.Add(1) + if removed, handled, err := f.interrupted(ctx, "hidden_references", call); handled { + return removed, err + } + if index := int(call - 1); index < len(f.hiddenResults) { + return f.hiddenResults[index], nil + } + return 0, nil +} + +func TestApplication_WiresNotificationRetention(t *testing.T) { + app := &application{cfg: &config.Config{}, credentialCipher: credentialciphertest.Fixed()} + app.buildRepositories() + + require.NotNil(t, app.notificationRetentionSweeper, "buildRepositories must wire the notification retention sweeper") +} + +func TestNotificationRetentionInterval_IsHourly(t *testing.T) { + require.Equal(t, time.Hour, notificationRetentionInterval) +} + +func TestStartNotificationRetentionJob_DrainsFullBatchesBeforeNextTick(t *testing.T) { + var logged bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + ctx, cancel := context.WithCancel(context.Background()) + var waitGroup sync.WaitGroup + sweeper := ¬ificationRetentionSweeperFake{ + readResults: []int64{10000, 10000, 7}, + unreadResults: []int64{10000, 0}, + groupResults: []int64{3}, + hiddenResults: []int64{10000, 4}, + } + startNotificationRetentionJob(ctx, &waitGroup, sweeper, time.Hour) + t.Cleanup(func() { + cancel() + requireDiscoverHotCleanupJobStops(t, &waitGroup) + }) + + testkit.WaitFor(t, time.Second, func() (bool, error) { + return sweeper.readCalls.Load() == 3 && sweeper.unreadCalls.Load() == 2 && sweeper.groupCalls.Load() == 1 && + sweeper.hiddenCalls.Load() == 2, nil + }, testkit.WithPollInterval(time.Millisecond), + testkit.WithDescription("the startup retention cycle to drain the read, unread and hidden-reference batches and sweep groups")) + + testkit.Holds(t, 150*time.Millisecond, func() (bool, error) { + return sweeper.readCalls.Load() == 3 && sweeper.unreadCalls.Load() == 2 && sweeper.groupCalls.Load() == 1 && + sweeper.hiddenCalls.Load() == 2, nil + }, testkit.WithPollInterval(5*time.Millisecond), + testkit.WithDescription("the retention sweeps to stop after their short batches until the next tick")) + + cancel() + requireDiscoverHotCleanupJobStops(t, &waitGroup) + assert.Regexp(t, `level=INFO .*sweep=hidden_references removed=10004`, logged.String(), + "the hidden-reference sweep must report its drained total under its own name") +} + +func TestStartNotificationRetentionJob_LogsReadErrorContinuesAndRetries(t *testing.T) { + var logged bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + ctx, cancel := context.WithCancel(context.Background()) + var waitGroup sync.WaitGroup + sweeper := ¬ificationRetentionSweeperFake{firstReadError: errors.New("retention read sweep sentinel failure")} + startNotificationRetentionJob(ctx, &waitGroup, sweeper, time.Millisecond) + t.Cleanup(func() { + cancel() + requireDiscoverHotCleanupJobStops(t, &waitGroup) + }) + + assert.Eventually(t, func() bool { + return sweeper.readCalls.Load() >= 2 + }, time.Second, time.Millisecond, "a later tick must retry the read sweep") + cancel() + requireDiscoverHotCleanupJobStops(t, &waitGroup) + + assert.True(t, sweeper.secondReadAfterAllSweeps.Load(), + "the unread, group and hidden-reference sweeps must run despite the first read error, before the next read cycle") + assert.GreaterOrEqual(t, sweeper.unreadCalls.Load(), int64(1)) + assert.GreaterOrEqual(t, sweeper.groupCalls.Load(), int64(1)) + assert.GreaterOrEqual(t, sweeper.hiddenCalls.Load(), int64(1)) + + var errorRecord string + for _, line := range strings.Split(logged.String(), "\n") { + if strings.Contains(line, "level=ERROR") && strings.Contains(line, "retention read sweep sentinel failure") { + errorRecord = line + break + } + } + require.NotEmpty(t, errorRecord, "an ERROR log record must carry the read sweep error") + assert.Regexp(t, `(?:^|\s)sweep=\S+`, errorRecord, "the ERROR record must identify the failed sweep") +} + +// expiringContext stands in for a cycle whose deadline passes mid-sweep: +// after expire it reports context.DeadlineExceeded, without the test +// depending on how long the sweep took to start. +type expiringContext struct { + context.Context + done chan struct{} + once sync.Once +} + +func newExpiringContext() *expiringContext { + return &expiringContext{Context: context.Background(), done: make(chan struct{})} +} + +func (c *expiringContext) Done() <-chan struct{} { return c.done } + +func (c *expiringContext) Err() error { + select { + case <-c.done: + return context.DeadlineExceeded + default: + return nil + } +} + +func (c *expiringContext) expire() { c.once.Do(func() { close(c.done) }) } + +func TestStartNotificationRetentionJob_StopsCycleWhenContextEnds(t *testing.T) { + cases := []struct { + name string + interruptSweep string + deadline bool + fullBatch bool + wantRead, wantUnread, wantGroup int64 + wantHidden int64 + skipped []string + }{ + {name: "cancelled during read sweep", interruptSweep: "read", + wantRead: 1, wantHidden: 0, skipped: []string{"unread_cap", "empty_groups", "hidden_references"}}, + {name: "deadline exceeded during read sweep", interruptSweep: "read", deadline: true, + wantRead: 1, wantHidden: 0, skipped: []string{"unread_cap", "empty_groups", "hidden_references"}}, + {name: "cancelled during a full read batch", interruptSweep: "read", fullBatch: true, + wantRead: 1, wantHidden: 0, skipped: []string{"unread_cap", "empty_groups", "hidden_references"}}, + {name: "deadline exceeded during unread_cap sweep", interruptSweep: "unread_cap", deadline: true, + wantRead: 1, wantUnread: 1, wantHidden: 0, skipped: []string{"empty_groups", "hidden_references"}}, + {name: "deadline exceeded during empty_groups sweep", interruptSweep: "empty_groups", deadline: true, + wantRead: 1, wantUnread: 1, wantGroup: 1, wantHidden: 0, skipped: []string{"hidden_references"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var logged bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + var ctx context.Context + var end func() + if tc.deadline { + expiring := newExpiringContext() + ctx, end = expiring, expiring.expire + } else { + ctx, end = context.WithCancel(context.Background()) + } + sweeper := ¬ificationRetentionSweeperFake{ + interruptSweep: tc.interruptSweep, + interrupt: func(cycleCtx context.Context) (int64, error) { + end() + <-cycleCtx.Done() + if tc.fullBatch { + return notifications.RetentionBatchSize, nil + } + return 0, cycleCtx.Err() + }, + } + var waitGroup sync.WaitGroup + // An hourly interval leaves only the startup cycle; ending its + // context also stops the job, so the wait below means that cycle + // has completed. + startNotificationRetentionJob(ctx, &waitGroup, sweeper, time.Hour) + t.Cleanup(end) + requireDiscoverHotCleanupJobStops(t, &waitGroup) + + assert.Equal(t, []int64{tc.wantRead, tc.wantUnread, tc.wantGroup, tc.wantHidden}, + []int64{sweeper.readCalls.Load(), sweeper.unreadCalls.Load(), sweeper.groupCalls.Load(), sweeper.hiddenCalls.Load()}, + "no sweep call may follow the end of the cycle context (read, unread_cap, empty_groups, hidden_references calls)") + for _, line := range strings.Split(logged.String(), "\n") { + for _, sweep := range tc.skipped { + assert.False(t, strings.Contains(line, "level=ERROR") && strings.Contains(line, "sweep="+sweep), + "a sweep skipped after the cycle context ended must not log a failure: %s", line) + } + } + }) + } +} + +var _ notifications.RetentionSweeper = (*notificationRetentionSweeperFake)(nil) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 7a92fee..acac7b8 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -86,6 +86,9 @@ func registerXRPCRoutes(r chi.Router, app *application) { routes.RegisterVoteRoutes(r, app.voteService, app.authMiddleware) routes.RegisterUserBlockRoutes(r, app.userBlockService, app.authMiddleware) routes.RegisterCommentRoutes(r, app.commentService, app.authMiddleware) + routes.RegisterNotificationRoutes(r, app.notificationService, app.authMiddleware) + routes.RegisterNotificationListRoutes(r, app.notificationListService, app.authMiddleware) + routes.RegisterNotificationPreferenceRoutes(r, app.preferencesService, app.authMiddleware) routes.RegisterAdminReportRoutes(r, app.adminReportService, app.authMiddleware) routes.RegisterModerationRoutes(r, app.moderationService, app.instanceAdminAuth) routes.RegisterCommunitySuggestionRoutes(r, app.communitySuggestionService, diff --git a/cmd/server/wiring.go b/cmd/server/wiring.go index 1725654..6300541 100644 --- a/cmd/server/wiring.go +++ b/cmd/server/wiring.go @@ -27,6 +27,7 @@ import ( "Coves/internal/core/discover" "Coves/internal/core/imageproxy" "Coves/internal/core/moderation" + "Coves/internal/core/notifications" "Coves/internal/core/posts" "Coves/internal/core/timeline" "Coves/internal/core/unfurl" @@ -108,11 +109,15 @@ type application struct { // the comment service's PostReader requires the admission-aware // VisibleHeaderView as well, and storing the narrower interface here would // erase it before the wiring could hand it over. - postRepo *postgresRepo.PostRepository - voteRepo votes.Repository - commentRepo comments.Repository - userBlockRepo userblocks.Repository - aggregatorRepo aggregators.Repository + postRepo *postgresRepo.PostRepository + voteRepo votes.Repository + commentRepo comments.Repository + // notificationRepo is handed to the consumers that write notifications. + notificationRepo notifications.Repository + // The same repository also performs the hourly retention sweeps. + notificationRetentionSweeper notifications.RetentionSweeper + userBlockRepo userblocks.Repository + aggregatorRepo aggregators.Repository // admissionRepo is shared by the ingestion consumer, which WRITES the // per-(community, post) decisions, and the status query, which reads them. admissionRepo posts.AdmissionRepository @@ -136,6 +141,9 @@ type application struct { acceptanceQueue *posts.QueueDriver voteService votes.Service commentService comments.Service + notificationService notifications.Service + notificationListService notifications.ListService + preferencesService notifications.PreferencesService userBlockService userblocks.Service adminReportService adminreports.Service moderationService moderation.Service @@ -370,6 +378,12 @@ func (a *application) buildRepositories() { a.postRepo = postgresRepo.NewPostRepository(a.db) a.voteRepo = postgresRepo.NewVoteRepository(a.db) a.commentRepo = postgresRepo.NewCommentRepository(a.db) + if len(a.cfg.Instance.TrustedBridgePDSHosts) > 0 { + a.notificationRepo = postgresRepo.NewNotificationRepository(a.db, postgresRepo.WithBridgedUpvoteTotals()) + } else { + a.notificationRepo = postgresRepo.NewNotificationRepository(a.db) + } + a.notificationRetentionSweeper = a.notificationRepo.(notifications.RetentionSweeper) a.userBlockRepo = postgresRepo.NewUserBlockRepository(a.db) a.aggregatorRepo = postgresRepo.NewAggregatorRepository(a.db, a.credentialCipher) a.admissionRepo = postgresRepo.NewAdmissionRepository(a.db) @@ -568,6 +582,9 @@ func (a *application) buildServices(ctx context.Context) error { a.commentRepo, a.userRepo, a.postRepo, a.communityRepo, a.oauthClient, a.oauthStore, nil, ) + a.notificationService = notifications.NewService(a.notificationRepo.(notifications.ReadRepository)) + a.notificationListService = notifications.NewListService(a.notificationRepo.(notifications.ReadRepository), a.userRepo, a.postRepo, a.commentRepo) + a.preferencesService = notifications.NewPreferencesService(a.notificationRepo.(notifications.PreferencesRepository)) a.userBlockService = userblocks.NewService(a.userBlockRepo, nil, a.oauthClient, a.oauthStore, nil) adminReportOptions, err := adminReportAlertOptions() if err != nil { @@ -984,7 +1001,8 @@ func (a *application) buildBridgedVotePoller() error { oauth.NewSSRFSafeHTTPClient(oauth.PrivateAddressOptions(a.allowPrivateHosts())...), ) poller, err := bridgedvotes.NewPoller( - postgresRepo.NewBridgedVotesRepository(a.db), client, hosts, bridgedvotes.Options{ + postgresRepo.NewBridgedVotesRepository(a.db, + postgresRepo.WithBridgedVoteNotifications(a.notificationRepo, a.bridgeTrust)), client, hosts, bridgedvotes.Options{ Lookback: a.cfg.Instance.BridgedVotePollLookback, SweepCap: a.cfg.Instance.BridgedVotePollSweepCap, }, diff --git a/cmd/server/wiring_bridged_upvote_test.go b/cmd/server/wiring_bridged_upvote_test.go new file mode 100644 index 0000000..5cb2479 --- /dev/null +++ b/cmd/server/wiring_bridged_upvote_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "testing" + + "Coves/internal/config" + "Coves/internal/crypto/credentialcipher/credentialciphertest" + + "github.com/stretchr/testify/require" +) + +func TestApplication_BridgedUpvoteTotalsFollowTrustedBridgeConfiguration(t *testing.T) { + for _, tc := range []struct { + name string + hosts []string + want bool + }{ + {"no trusted hosts", nil, false}, + {"trusted host", []string{"https://bridge.test"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + app := &application{ + cfg: &config.Config{Instance: config.InstanceConfig{TrustedBridgePDSHosts: tc.hosts}}, + credentialCipher: credentialciphertest.Fixed(), + } + app.buildRepositories() + repo, ok := app.notificationRepo.(interface{ CountsBridgedUpvoteTotals() bool }) + require.True(t, ok, "notification repository must expose its bridge-total gate") + require.Equal(t, tc.want, repo.CountsBridgedUpvoteTotals()) + // The retention job sweeps through its own field; it must be the same + // configured repository, not one built without the bridge-total option. + sweeper, ok := app.notificationRetentionSweeper.(interface{ CountsBridgedUpvoteTotals() bool }) + require.True(t, ok, "notification retention sweeper must expose its bridge-total gate") + require.Equal(t, tc.want, sweeper.CountsBridgedUpvoteTotals(), + "the retention sweeper must follow the trusted bridge configuration") + }) + } +} diff --git a/cmd/server/wiring_bridgedvotes_notifications_test.go b/cmd/server/wiring_bridgedvotes_notifications_test.go new file mode 100644 index 0000000..ebbc911 --- /dev/null +++ b/cmd/server/wiring_bridgedvotes_notifications_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "testing" + + "Coves/internal/config" + "Coves/internal/crypto/credentialcipher/credentialciphertest" + "Coves/internal/db/postgres" + + "github.com/stretchr/testify/require" +) + +func TestBuildBridgedVotePoller_WiresNotificationsAndBridgeTrust(t *testing.T) { + a := &application{ + cfg: &config.Config{Instance: config.InstanceConfig{ + TrustedBridgePDSHosts: []string{"https://bridge.test"}, + }}, + credentialCipher: credentialciphertest.Fixed(), + } + a.buildRepositories() + a.buildJetstreamInfrastructure() + require.NoError(t, a.buildBridgedVotePoller()) + require.NotNil(t, a.notificationRepo) + require.NotNil(t, a.bridgeTrust) + require.NotNil(t, a.bridgedVotePoller) + store := a.bridgedVotePoller.Store() + require.IsType(t, (*postgres.BridgedVotesRepository)(nil), store) + repo := store.(*postgres.BridgedVotesRepository) + notificationRepo, bridgeTrust := repo.NotificationWiring() + require.NotNil(t, notificationRepo) + require.NotNil(t, bridgeTrust) + require.Same(t, a.notificationRepo, notificationRepo) + require.Same(t, a.bridgeTrust, bridgeTrust) +} diff --git a/cmd/server/wiring_notifications_test.go b/cmd/server/wiring_notifications_test.go new file mode 100644 index 0000000..bf19927 --- /dev/null +++ b/cmd/server/wiring_notifications_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "testing" + + "Coves/internal/config" + "Coves/internal/crypto/credentialcipher/credentialciphertest" + + "github.com/stretchr/testify/require" +) + +func TestApplication_WiresCommentReplyNotifications(t *testing.T) { + app := &application{cfg: &config.Config{}, credentialCipher: credentialciphertest.Fixed()} + app.buildRepositories() + + require.NotNil(t, app.notificationRepo, "buildRepositories must construct the notification repository") + require.True(t, app.buildCommentConsumer().NotificationsWired(), + "the comment consumer registered for the feed must carry the notification repository") +} + +// Without the bridge trust, the comment consumer would notify recipients hosted +// on a trusted bridge PDS. +func TestApplication_WiresCommentBridgeTrust(t *testing.T) { + const trustedBridgeHost = "https://bridge.test" + app := &application{ + cfg: &config.Config{Instance: config.InstanceConfig{TrustedBridgePDSHosts: []string{trustedBridgeHost}}}, + credentialCipher: credentialciphertest.Fixed(), + } + app.buildRepositories() + app.buildJetstreamInfrastructure() + + require.True(t, app.bridgeTrust.TrustsPDS(trustedBridgeHost), + "buildJetstreamInfrastructure must build the bridge trust from the configured hosts") + require.True(t, app.buildCommentConsumer().BridgeTrustWired(), + "the comment consumer registered for the feed must carry the bridge trust") +} + +func TestApplication_WiresVoteNotificationsAndErasureGate(t *testing.T) { + app := &application{cfg: &config.Config{}, credentialCipher: credentialciphertest.Fixed()} + app.buildRepositories() + + require.NotNil(t, app.notificationRepo, "buildRepositories must construct the notification repository") + consumer := app.buildVoteConsumer() + require.True(t, consumer.NotificationsWired(), + "the vote consumer registered for the feed must carry the notification repository") + require.True(t, consumer.ErasureGated(), "the existing erased-subject gate must remain wired") +} + +func TestApplication_WiresVoteBridgeTrust(t *testing.T) { + const trustedBridgeHost = "https://bridge.test" + app := &application{ + cfg: &config.Config{Instance: config.InstanceConfig{TrustedBridgePDSHosts: []string{trustedBridgeHost}}}, + credentialCipher: credentialciphertest.Fixed(), + } + app.buildRepositories() + app.buildJetstreamInfrastructure() + + require.True(t, app.bridgeTrust.TrustsPDS(trustedBridgeHost), + "buildJetstreamInfrastructure must build bridge trust from the configured hosts") + consumer := app.buildVoteConsumer() + require.True(t, consumer.BridgeTrustWired(), + "the vote consumer registered for the feed must carry bridge trust") + require.True(t, consumer.ErasureGated(), "the existing erased-subject gate must remain wired") +} + +func TestApplication_WiresPostNotifications(t *testing.T) { + app := &application{cfg: &config.Config{}, credentialCipher: credentialciphertest.Fixed()} + app.buildRepositories() + + require.NotNil(t, app.notificationRepo, "buildRepositories must construct the notification repository") + require.True(t, app.buildPostConsumer().NotificationsWired(), + "the post consumer registered for the feed must carry the notification repository") +} diff --git a/docs/LEXICON_PUBLISHING.md b/docs/LEXICON_PUBLISHING.md index 9239388..a69e2c7 100644 --- a/docs/LEXICON_PUBLISHING.md +++ b/docs/LEXICON_PUBLISHING.md @@ -84,7 +84,8 @@ moderation package: `moderation/defs.json` and the seven endpoint files `moderation/removeContent.json`, `moderation/restoreContent.json`, `moderation/labelContent.json`, `moderation/retractContentLabel.json`, `moderation/listActions.json`, `moderation/listAdminActions.json`, -`moderation/getSubjectState.json`. +`moderation/getSubjectState.json`; and, once the notifications release below +runs, `notification/defs.json` and its five endpoint files. **Held back** (governance/tribunal design has not landed; each is listed by path relative to `internal/atproto/lexicon/social/coves/`, and the test fails @@ -103,7 +104,7 @@ if a publish command names any of them or a directory that contains them): `goat lex publish` sweeps every schema in a directory operand, so the commands below name files, never the `moderation` or `community` directory. Retired -schemas stay live until explicitly unpublished (see step 4). +schemas stay live until explicitly unpublished (see step 5). ## One-time setup @@ -135,18 +136,20 @@ schemas stay live until explicitly unpublished (see step 4). | `_lexicon.vote.feed.coves.social` | `did=` | | `_lexicon.richtext.coves.social` | `did=` | | `_lexicon.moderation.coves.social` | `did=` — created in the moderation release, step 3 | + | `_lexicon.notification.coves.social` | `did=` — created in the notifications release, step 4 | Via the Cloudflare API (token needs Zone:DNS:Edit on `coves.social`): ```sh CF_API_TOKEN=... LEXICON_DID=did:plc:... ./scripts/publish-lexicon-dns.sh # add --include-moderation for the moderation release (step 3 below) + # add --include-notification for the notifications release (step 4 below) ``` ## Publish / update workflow Step 0 is run on every publish. Steps 1–2 republish the already-live -namespaces. Step 3 is the moderation release, run once in the order given. -Step 4 verifies. Every operand is an explicit file. +namespaces. Step 3 is the moderation release and step 4 the notifications +release, each run once in the order given. Step 5 verifies. Every operand is an explicit file. ```sh # 0. Gates — all must pass; review the lint/breaking/diff output by hand @@ -266,11 +269,29 @@ goat lex publish \ internal/atproto/lexicon/social/coves/moderation/listAdminActions.json \ internal/atproto/lexicon/social/coves/moderation/getSubjectState.json -# 4. Verify resolution end-to-end, and unpublish anything retired. +# 4. Notifications release (once). notification.defs references actor.defs +# and community.post.defs, so it runs after step 3d; the endpoints +# reference notification.defs, so defs go first. +# 4a. DNS delegation for the new authority, then confirm resolution: +CF_API_TOKEN=... LEXICON_DID=did:plc:... ./scripts/publish-lexicon-dns.sh --include-notification +goat lex check-dns internal/atproto/lexicon/social/coves/notification/defs.json +# 4b. Shared definitions, then the five endpoints: +goat lex publish \ + internal/atproto/lexicon/social/coves/notification/defs.json +goat lex resolve social.coves.notification.defs +goat lex publish \ + internal/atproto/lexicon/social/coves/notification/getUnreadCount.json \ + internal/atproto/lexicon/social/coves/notification/listNotifications.json \ + internal/atproto/lexicon/social/coves/notification/updateSeen.json \ + internal/atproto/lexicon/social/coves/notification/getPreferences.json \ + internal/atproto/lexicon/social/coves/notification/putPreferences.json + +# 5. Verify resolution end-to-end, and unpublish anything retired. goat lex check-dns internal/atproto/lexicon/social/coves goat lex status internal/atproto/lexicon/social/coves goat lex resolve social.coves.community.post goat lex resolve social.coves.moderation.listActions +goat lex resolve social.coves.notification.listNotifications # Retired schemas: publish only writes what it is given, so a schema whose # file was deleted stays live until explicitly unpublished. # social.coves.community.post.search was published by the 2026-07 directory diff --git a/docs/NOTIFICATIONS_IMPLEMENTATION.md b/docs/NOTIFICATIONS_IMPLEMENTATION.md new file mode 100644 index 0000000..6fcd1f0 --- /dev/null +++ b/docs/NOTIFICATIONS_IMPLEMENTATION.md @@ -0,0 +1,205 @@ +# In-App Notifications: implementation notes + +How in-app notifications work in the AppView. The product spec is [PRD_NOTIFICATIONS.md](PRD_NOTIFICATIONS.md). This file records the product decisions made during the build that refine it, how the code fits together, and what to know when running it. + +## 1. Summary + +The AppView now writes and serves in-app notifications. Nothing is pushed: clients poll. + +- **Endpoints** (all `social.coves.notification.*`, all behind `RequireAuth`, the caller is always the recipient): + - `getUnreadCount` (GET): `{count}`, capped at 101 ("100+"). + - `listNotifications` (GET): keyset-paged list, `limit` 1..100 (default 50), opaque `cursor`, page-level `seenAt`, per-row `isRead`. + - `updateSeen` (POST): advances the monotonic, server-clamped `seen_at` watermark. + - `getPreferences` (GET) and `putPreferences` (POST): per-reason on/off; `putPreferences` returns the full object. +- **Reasons**: `postReply`, `commentReply`, `mention` (in comments and postv2 posts, on create and when an edit adds a mention), and `upvote` groups (one row per item, bumped to the top on a new qualifying upvote; `upvoteCount` includes trusted-bridge (Lemmy via Tidepool) totals; `recentUpvoters` names up to 3 native voters). +- **Placeholders**: deleted or moderator-removed content keeps its notifications. The view carries `status` (`deleted`, `removedByModerator`, `removedByServerAdmin`) and no text; it gets no new notifications. +- **Previews**: post title or 140-grapheme body excerpt, comment excerpt, the author's self-labels (`labels`), and post `thumbnail` / `thumbnailAlt` through the image proxy. +- **Maintenance**: account erasure deletes a user's rows; an hourly retention job prunes read, overflow and empty-group rows. + +## 2. Product decisions made during the build + +These refine the PRD and are binding on the code. Tests cite them by their Q labels. The PRD's own decisions cover the rest: Q10 is PRD decision 7 (deleted and removed content), Q11 and Q15 are decision 8 (bridged upvote totals and the high-water mark), and Q12 to Q14 are decision 9 (labels, thumbnails, blank titles, upvoter order). + +- **Q1, community blocks**: blocking a community does not hide notifications. Only user blocks hide them, in either direction. +- **Q2, excerpts**: every excerpt is 140 grapheme clusters (comment preview, untitled-post body fallback, `record` excerpt). A post mention's `record` excerpt is the post title, or the body when there is no title. +- **Q3**: `putPreferences` returns the full preferences object. +- **Q4, who counts as an upvoter**: any voter who is not erased and not an aggregator. Voters on a trusted bridge PDS count. This applies to bumps, `upvoteCount`, `recentUpvoters` and deciding when a group is empty. +- **Q5, activation**: an edit to a record created before notifications launched never notifies. Only the 7-day freshness check uses the edit's time. +- **Q6, edit freshness**: a mention added by an edit is fresh when the edit's Jetstream event time is within 7 days. With no event time, index time is used. +- **Q7, re-upvote spam**: a voter bumps an item's upvote group once, ever. Removing and re-adding an upvote does not bump again, and a group deleted when the voter un-voted is not recreated by their re-vote. An earlier downvote does not count as an earlier upvote. +- **Q8, mention cap**: at most 10 mention notifications per record, in total, across create, every edit, re-create and resurrection. A slot freed by retention or erasure can be reused. +- **Reply/mention dedup**: someone notified as a record's reply recipient is never also notified of a mention by that record, after edits or re-creates too. The rule keys on who the reply recipient is, so a reply row lost to retention or a lifted block does not re-open the mention. +- **Q10 addition**: a live reply under a deleted root keeps its own excerpt; only the withdrawn reference loses its text. +- **Q15 accepted trade-offs**: after a group is deleted at zero, a re-rise only up to the old peak recreates nothing. A spurious spike raises the peak permanently. +- **Admin moderation removals (2026-10-06)**: + - An active admin removal (`moderation_decisions`, kind `removal`) follows the Q10 placeholder rule. A notification whose root post, subject or triggering record is removed keeps its row, is listed and counted, and shows no text, excerpt, labels, thumbnail, title or community for that reference. Label decisions change nothing. + - Status: any active instance-scope removal gives `removedByServerAdmin`, even alongside a community removal; community-scope removals alone give `removedByModerator`. + - A post gets the placeholder only if it was publicly visible; a pending or rejected post stays hidden. An author's delete wins: a deleted post or comment reads `deleted` (or stays hidden without the marker). + - Removed content gets no new notifications, including a comment or post whose own URI carries a removal. Restoring (decision inactive) does not backfill; the reference reads live again. + - A comment reference whose root post carries an active removal reads the root's removal status, so a reply's excerpt is not shown inside a removed thread (main's comment lists hide such replies the same way). Its own `deleted_at` still wins. Q10's "live reply under a deleted root keeps its excerpt" still holds for author deletes and community withdrawals. + - An admin removal does not stop the withdrawal markers: an admitted public post that is later author-deleted or community-removed still gets its marker, so its rows keep their placeholder. +- **Unindexed thread references (2026-10-06)**: a comment whose root post or parent (post or comment) has no indexed row fans out nothing, replies and mentions alike, so a comment pointing at a nonexistent post cannot plant rows that stay hidden forever. Accepted trade-off: a comment that arrives before its root or parent loses its notifications; nothing backfills them. +- **Hidden-reference sweep (2026-10-06)**: rows whose required reference reads hidden (unindexed, never public, removed or deleted without a marker, unsupported collection) are deleted once `sort_at` is older than 7 days. Rows hidden only by a block, a disabled preference or the upvote alive rule are kept. A reply to a post still pending admission after 7 days is lost. +- **Malformed comment labels (2026-10-06)**: a live comment reference whose stored self-labels do not parse omits its row (counted as `malformed_labels` in the omission warning) rather than showing the excerpt unlabeled. + +## 3. How it works + +### Schema and tables + +- `internal/db/migrations/053_notifications.sql`: + - `notifications`: `recipient_did` (FK `users` ON DELETE CASCADE), `reason`, `record_uri`, `record_cid`, `actor_did`, `subject_uri`, `root_post_uri` (NOT NULL, the navigation target), `record_created_at` (display only), `sort_at` (stamped with `clock_timestamp()` at the write, not the transaction-start `now()`; raised by bumps). A CHECK fixes the shape per reason: upvote groups have NULL record and actor; mentions have NULL subject. + - Unique indexes: `uq_notifications_record (recipient_did, reason, record_uri) WHERE reason <> 'upvote'` and `uq_notifications_upvote_group (recipient_did, subject_uri) WHERE reason = 'upvote'`. Paging index `idx_notifications_recipient_sort (recipient_did, sort_at DESC, id DESC)`. + - `notification_state (did PK FK users, seen_at NULL = never seen, disabled_reasons TEXT[])`. + - `notification_activation`: one row, `activated_at = NOW()` at migration time. A missing row is an error (`postgres.ErrNotificationActivationMissing`), never "no cutoff". + - `posts.bridged_upvote_peak` and `comments.bridged_upvote_peak` (INT NOT NULL DEFAULT 0, CHECK >= 0), backfilled from `bridged_upvote_count` (Q15). +- `054_votes_upvote_history_index.sql`: `idx_votes_voter_subject_upvotes ON votes (voter_did, subject_uri) WHERE direction='up'`, built `CONCURRENTLY` with goose `NO TRANSACTION`, for `EarlierUpvoteExists`. +- `055_notification_public_post_withdrawals.sql`: `(post_uri, kind IN ('authorDelete','communityWithdrawal'), community_rev, recorded_at)`, PK `(post_uri, kind)`, `community_rev` NULL exactly for `authorDelete`, no FK. Postv2 posts only. +- Any later migration must add a `MigrateDownOne` step to each rollback site in `internal/db/postgres` (11 call sites today, grep `MigrateDownOne(t, db, 55`) and update the "035 through 055" comment in `admission_repo_schema_test.go`. +- No new env vars. Retention thresholds are constants in `internal/core/notifications/notification.go`. + +### Package layout + +- `internal/core/notifications`: `notification.go` (types, constants), `interfaces.go` (`Lookups`, `Repository`, `ReadRepository`, `RetentionSweeper`, `PreferencesRepository`), `fanout.go` (pure fan-out functions), `service.go` (`Service`, `PreferencesService`), `list_service.go` + `view.go` (`ListService`, view types), `cursor.go`. +- `internal/db/postgres`: `notification_repo.go` (writes, lookups, SQL fragments, retention), `notification_visibility.go` (read predicate, unread count), `notification_list.go`, `notification_seen.go`, `notification_preferences.go`; `bridged_votes_repo.go` (bridged poller write path); `admission_repo.go` (community-withdrawal markers). +- Consumers depend on `notifications.Repository` and never import the service. Fan-out functions return intents; `Repository.ApplyTx` / `ApplyUpvoteGroupTx` write them inside the consumer's own index transaction. + +### Write path: transactions and the erasure lock + +- Every notification-writing consumer transaction runs at READ COMMITTED (`BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})`). `ErasureGateTx` checks isolation first and returns `postgres.ErrErasureGateRequiresReadCommitted` otherwise. +- `ErasureGateTx(tx, actorDID)` takes the shared `pg_advisory_xact_lock` on `postgres.ErasureLockKeySQL` (`hashtext('erasure:' || $1)`), then checks `deleted_accounts` in a separate statement. It runs right after `tryAdvanceRecordRev` wins and before any content row is read for update. Moving it later deadlocks with erasure. +- Erasure (`user_repo.go` `Delete`): exclusive advisory lock first, then the `deleted_accounts` marker, content deletes, `DELETE FROM notifications WHERE recipient_did=$1 OR actor_did=$1`, the user's withdrawal markers, `notification_state`, then `users`. Upvote groups on other users' items are left for the sweep. Moving the notification delete before the content deletes deadlocks (pinned by a test). +- Lock order everywhere: content (subject) row → notification/group row → `users` FK. There is no explicit `FOR KEY SHARE`; the `recipient_did` FK provides it. +- A recipient erased mid-fan-out raises 23503 on `notifications_recipient_did_fkey`. `ApplyTx` and the group upsert skip that case inside a savepoint and keep the caller's earlier writes; every other error aborts the transaction and the event is retried or dead-lettered. +- `ApplyTx` writes all record-keyed intents in one savepoint with one `unnest` INSERT `ON CONFLICT (recipient_did, reason, record_uri) WHERE reason <> 'upvote' DO NOTHING`. Only on that 23503 does it fall back to per-intent savepoints (Postgres caches 64 subtransaction XIDs per backend). +- Unparsable thread URIs give "no recipient" (`nil, nil`), never an error, so a malformed record cannot stall a Jetstream lane. + +### Write path: hook points + +- Comments (`internal/atproto/jetstream/comment_consumer.go`): + - New insert, unsupported-parent early commit and different-parent resurrection go through `writeCreateNotificationsAfterCounts` (repair → fan-out unless erased → `DeleteReplyRecipientMentionsTx`), after the parent/root count updates. + - Same-parent resurrection calls `writeCommentCreateNotifications` then `DeleteReplyRecipientMentionsTx` before its early commit. + - `updateComment` and the active re-create (newer rev, new CID) share `writeCommentEditNotifications`. Both read stored facets, `created_at`, `parent_uri`, `root_uri` in one `FOR UPDATE` read inside the transaction. The re-create uses the pre-update `created_at` for activation. + - `deleteComment` takes the gate (lock only) and keeps rows. + - A different-root resurrection runs `ReplaceUpvoteGroupRootTx` for the commenter's group. +- Votes (`vote_consumer.go`): `indexVoteAndUpdateCounts` final commit applies `FanoutVoteCreate`; the stale-vote replacement early commit applies `FanoutVoteRemoval`; `deleteVote` applies `FanoutVoteRemoval` after the count decrement. The gate is used for its lock only; `FanoutVoteCreate` owns voter eligibility. A comment subject's root is read after the count UPDATE, under the row lock (`voteSubjectRootAfterCountUpdate`). +- Posts (`post_consumer.go`, `authorpost.go`): `indexPostIfRevWins` (Jetstream and direct fetch) → `writePostCreateNotifications`; the direct fetch passes no rev and serializes on `INSERT ... ON CONFLICT (uri) DO NOTHING`, so the mention-budget read must stay after the INSERT. `applyPostContentUpdate` reads stored facets and `created_at` `FOR UPDATE`, then `FanoutPostEdit`. `tombstoneRecordIfRevWins` takes the gate, soft-deletes, then `RecordPostAuthorDeleteWithdrawalTx`. +- Pending admission (`UpsertPending`) is written after the post insert transaction, not inside it. +- Wiring options: `jetstream.WithCommentNotifications`, `WithVoteNotifications` (also installs the voter's erasure lock), `WithVoteBridgeTrust`, `WithPostNotifications`; each consumer exposes `NotificationsWired()` / `BridgeTrustWired()` for the `cmd/server` wiring tests. Builders: `buildCommentConsumer`, `buildVoteConsumer`, `buildPostConsumer` in `cmd/server/consumers.go`. + +### Fan-out and write-time gates (`fanout.go`) + +- Order of checks, shared by every path: the withdrawn gate (`anyWithdrawn`) and record-level time gates (`notificationRecordAllowed`: activation on the record's `createdAt`, then 7-day freshness against index time) run once per record; then one batched `RecipientFacts` call in `notificationAllowedRecipients` (absent from `users` = not indexed, erased, aggregator, recipient's `users.pds_url` on a trusted bridge, block in either direction with the actor). The actor's own PDS never suppresses. The self rule is checked outside `notificationAllowedRecipients`. +- `BridgeHostChecker` implementations must be safe on a nil receiver; nil trusts no host. +- Replies (`FanoutCommentCreate`, `resolveCommentReply`): `postReply` to the root post's author for a top-level comment, `commentReply` to the parent comment's author otherwise. The root must parse and be a post collection. Postv2 author = repo DID; legacy author = `posts.author_did`; a direct reply to a legacy post with no row gives no intents at all (`errMissingLegacyPost`). +- Mentions: `richtext.MentionedDIDs` returns at most 200 distinct DIDs (parse bound, logged as "past the facet parse bound"). Mentions are dropped for the author, community DIDs and the resolved reply recipient (whether or not the reply survived its gates), and require a post root. Edits diff new against stored facets (`addedMentionDIDs`); removed mentions are never retracted. +- Mention cap (`notificationCappedMentions`, `MaxMentionsPerRecord = 10`): budget = 10 minus existing `mention` rows for the `record_uri` (`ExistingMentionRecipients`), read inside the writing transaction; recipients already holding a row are dropped; the rest are cut in facet order. Kept rows on deleted or resurrected records count. +- Edit freshness: `CommentRecord.EditEventTime` / `PostRecord.EditEventTime` from `eventTime(time_us)`; zero skips freshness (index-time fallback). Suppressed when `EditEventTime < IndexTime − 7d`; exactly 7 days is allowed. Activation still uses the stored `createdAt`. +- A non-RFC3339 `createdAt` parses as `time.Now()` and passes both replay gates (filed: `2026-09-29-malformed-createdat-defeats-notification-replay-bounds`). Future `createdAt` is clamped to now by the consumers. +- Withdrawn gate (`Lookups.ReferenceStates`, one statement): comments with `deleted_at`, posts with `deleted_at` or an own-community admission `status='removed'`, any URI with an active admin removal (indexed or not), and `ReferenceUnindexed` for a post or comment URI with no indexed row; no marker read (covers legacy posts). A URI in any other collection can never be indexed and reads live. Comment create/edit check the comment's own URI, root and parent; post create/edit check the post; vote create checks subject and root (returns DeleteIfEmpty, never Bump); bridged new highs (above `GREATEST(peak, stored count)`) check subject and root. `FanoutVoteRemoval` and the resurrection repair are not gated. Indexed pending or rejected posts read live here. This write-time notion differs from the read-time placeholder classification below. +- A block created later keeps existing rows; read-time filtering hides them. + +### Upvote groups + +- `FanoutVoteCreate` order: subject author (postv2 URI authority; legacy `LegacyPostAuthor`; comment = the commenter, whatever its root) → direction (`down` → DeleteIfEmpty with no lookups) → post root (a comment whose stored root is not a post never bumps) → self → withdrawn subject or root → activation/freshness on the vote's `createdAt` → voter erased → voter aggregator → recipient facts → `EarlierUpvoteExists` last (true → DeleteIfEmpty). Unresolvable subjects give NoChange; every other non-bump gives DeleteIfEmpty. +- `EarlierUpvoteExists` = any other `votes` row with the same voter and subject, `direction='up'`, different URI, live or soft-deleted. It requires `VoteRecord.URI`. +- `ApplyUpvoteGroupTx`: Bump = `INSERT ... ON CONFLICT (recipient_did, subject_uri) WHERE reason='upvote' DO UPDATE SET sort_at = GREATEST(...)` in `SAVEPOINT notification_upvote_group`; `root_post_uri` is written on insert only. DeleteIfEmpty = `DELETE ... WHERE NOT (alive)`, no savepoint. The caller must hold the subject's posts/comments row lock (or re-check in a separate statement); at READ COMMITTED the DELETE's subquery reads its original snapshot. +- `qualifyingUpvoteSQL(voteAlias, subjectExpr, recipientExpr)` in `notification_repo.go` is the one per-vote fragment: subject match, live, `up`, voter ≠ recipient, voter not erased, not an aggregator, no block either direction. No time, users-row or bridge conditions. It panics on unsafe aliases or expressions. +- `upvoteGroupAliveSQL(subjectExpr, recipientExpr, bridgedTotals)` is the one alive rule: a qualifying native upvote exists OR (gate on) the post or comment has `bridged_upvote_count > 0`. The bridged term deliberately ignores `deleted_at` (Q10 keeps a deleted item's count; the poller never writes deleted rows, so the total freezes). Used by DeleteIfEmpty, both sweep statements, the read predicate and the list aggregate. `bridgedUpvoteTotalSQL` gives the total (post wins over comment; `0` gate off). Both guarded by `requireUpvoteGroupOuterExpression`. +- Two concurrent first upvotes by one voter under different rkeys can both bump; `GREATEST` makes it one visible bump. +- Vote hard deletes reopen a voter's first-upvote bump: account erasure (expected) and `cmd/reindex-votes` (`DELETE FROM votes` then refetch; every voter could bump again after a run). + +### Bridged upvote totals + +- Gate: `postgres.NewNotificationRepository(db, postgres.WithBridgedUpvoteTotals())`, passed in `cmd/server/wiring.go` iff `cfg.Instance.TrustedBridgePDSHosts` is non-empty. The constructor builds `countUnreadSQL`, `listSQL`, `listUpvotesSQL` once; `CountsBridgedUpvoteTotals()` reports the gate. The retention sweeper is the same instance. No read statement joins `communities.pds_url` and there is no provenance column. +- Only the poller writes groups from bridged totals: `BridgedVotesRepository.ApplyAggregate` (one READ COMMITTED tx) → `lockBridgedAggregateSubject` (`SELECT bridged_upvote_count, bridged_upvote_peak [, root_uri] ... deleted_at IS NULL FOR UPDATE`, posts then comments) → the guarded UPDATE (millisecond `>=` on `asOf`, score recompute, `bridged_upvote_peak = GREATEST(bridged_upvote_peak, bridged_upvote_count, new upvotes)`) → `notifications.FanoutBridgedUpvoteChange` with the locked previous total and peak → `ApplyUpvoteGroupTx`. A stale `asOf` changes nothing. A notification error rolls back the aggregate and the sweep aborts before `MarkPolled`. A deleted or missing subject counts as stale. +- `FanoutBridgedUpvoteChange`: decrease → DeleteIfEmpty (alive rule decides); new total ≤ `GREATEST(peak, stored count)` → NoChange before checking the post root, withdrawn state or recipient; otherwise post root via `bumpRootPostURI`, `anyWithdrawn`, `notificationAllowedRecipients` with no actor, then Bump. No activation, freshness, `asOf` age, self or first-upvote check. The effective launch baseline is `GREATEST(peak, stored count)`, including rows whose stored count was written without a peak. +- The poller takes no erasure advisory lock (no actor). Lock order subject row → group row → `users` FK matches erasure. +- Wiring: `NewBridgedVotesRepository(db, WithBridgedVoteNotifications(notificationRepo, bridgeTrust))` in `buildBridgedVotePoller`, which runs only with trusted hosts configured. Test seams: `bridgedvotes.Poller.Store()`, `BridgedVotesRepository.NotificationWiring()`. +- The poller only sweeps candidates within its lookback (`BRIDGED_VOTE_POLL_LOOKBACK`, default 90 days by `created_at`), every `BRIDGED_VOTE_POLL_INTERVAL` (default 5 min). +- The Jetstream `bridgedStats` record path is not hooked: it accepts totals only for bridge-PDS authors, whom fan-out always rejects. +- Behaviour to expect (Q15): an oscillating total (5 → 4 → 5) does not bump on the return to 5; only a new high (6) bumps. A group deleted at zero is not recreated when the total climbs back only to its old peak. A peak raised by a spurious spike is never lowered. A removed bridge's totals stay counted and frozen. A compromised trusted bridge can still bump once per poll by reporting a steadily rising total, capped by `bridgedvotes.MaxBridgedCount`. A bridged-only group renders `upvoteCount` with no `recentUpvoters`. Turning the gate off makes the sweep permanently delete bridged-only groups; turning it back on does not restore them. +- Coves treats bridged and native votes as disjoint (score adds both); Tidepool serves a fediverse-only total net of votes it wrote back for native users. There is no dedupe here. + +### Deleted content, resurrection and public-withdrawal markers + +- Author deletes keep rows (`deleteComment`, `tombstoneRecordIfRevWins`, including a zero-row soft delete). +- `RecordPostAuthorDeleteWithdrawalTx` writes an `authorDelete` marker in the tombstone transaction when the post (postv2) passes `admittedPostsPredicate(anonymousViewerSQL)` (admitted publicly; an active admin removal does not prevent the marker); it ignores `deleted_at` because it runs after the soft delete. +- `communityWithdrawal` markers are written by `internal/db/postgres/admission_repo.go` (`compareAndSwapWithWithdrawal`, used by `ApplyAcceptanceDelete` and `ApplyRemoval`) in the admission transaction: lock the admission row `FOR UPDATE`, compute "admitted before" (postv2, `deleted_at IS NULL`, own-community post, `admittedPostsPredicate` for the anonymous viewer; no row = not admitted), run the existing guarded upsert, then upsert the marker with the event rev if it was admitted; a non-admitted `ApplyRemoval` deletes a marker whose rev differs (same-rev carry-forward). `ApplyRemovalDelete` does not touch markers. A marker-write failure rolls back the admission write. This is the one cross-domain write. +- Different-parent resurrection: `RepairResurrectedCommentNotificationsTx` deletes reply rows whose subject is not the new reply subject (`notifications.CommentReplySubject`) and repoints `root_post_uri` when the new root is a post; after fan-out, `DeleteReplyRecipientMentionsTx` removes a mention held by someone who now holds a reply row. Both resurrection branches call it. +- Mentions added by a re-create store the pre-update `createdAt` as `record_created_at`; `listNotifications` shows `record_created_at`. + +### Erasure and withdrawal summary + +- Account erasure deletes the user's received and sent notifications, their `notification_state`, and both marker kinds for their posts (`split_part(post_uri,'/',3) = did`, served by an expression index from migration 055). Groups on other users' items lose the voter through `qualifyingUpvoteSQL` (erased voters do not qualify) and are swept when empty. +- After erasure, `root_post_uri` in other users' rows still names the erased thread author's post (matches `comments.root_uri` behaviour). + +### Retention (`notification_repo.go`, `cmd/server/notification_retention_job.go`) + +- Constants: `RetentionReadWindow = 720h`, `RetentionUnreadCap = 500`, `RetentionUnreadWindow = 4320h`, `RetentionHiddenReferenceWindow = 168h`, `RetentionBatchSize = 10_000`. +- `RetentionSweeper`: `SweepReadNotifications` (rows whose `sort_at` is more than 30 days before the reference `COALESCE(seen_at, recipient's newest sort_at)`), `SweepUnreadOverflow` (only for users with a non-NULL `seen_at` and strictly more than 500 unread rows: deletes unread rows more than 180 days older than their newest row), `SweepEmptyUpvoteGroups` (candidate SELECT then a DELETE that re-checks the alive rule in a separate statement; no subject lock needed), `SweepHiddenReferenceNotifications` (rows with `sort_at` older than 7 days (`now() - 168h`) that fail `referencesVisible`, the reference-only half of the read predicate; no lower age bound, so rows missed during downtime or hidden later are still removed, and the lateral lookups per statement are bounded by the batch limit). Each is one READ COMMITTED tx deleting at most 10,000 rows via `FOR UPDATE OF n SKIP LOCKED`; windows are `$hours::bigint * INTERVAL '1 hour'`. +- Job: `runTicker(..., "notification-retention", 1h, ...)`, sweeps read → unread_cap → empty_groups → hidden_references, each looped while a batch returns 10,000. Errors are logged (`notification retention sweep failed`) and the next sweep still runs. When the cycle context ends, the cycle stops before the next batch or sweep and logs one line. Registered in `main.go` with `app.notificationRetentionSweeper`. +- For never-seen users the read reference is the newest row of any kind, so a hidden newest row (pending post, block-hidden) can delete their only visible unread row. PRD-literal. Placeholder rows are visible, so they do not cause it. +- After retention prunes a mention row, removing and re-adding the mention in an edit re-notifies. + +### Read path: visibility predicate (`notification_visibility.go`) + +- `notificationVisibility(bridgedTotals)` is the one predicate builder, used by `CountUnread` and `List`. It returns `joins`, the five state expressions (`subjectPostState`, `recordPostState`, `rootPostState`, `recordCommentState`, `subjectCommentState`) and `visible`. Extend it there, nowhere else. +- Post positions are `LEFT JOIN LATERAL` over `posts` with `admittedPostsPredicate(anonymousViewerSQL)` (reused from `post_visibility.go`, never copied). State precedence: community withdrawal (own-community admission `status='removed'`, joined on `a.community_did = p.community_did AND a.post_uri = p.uri`, AND a `communityWithdrawal` marker; `removedByServerAdmin` under an active instance removal, else `removedByModerator`) > admitted and undeleted (live, or the admin-removal status) > `deleted` (`authorDelete` marker) > hidden. Comments: deleted = placeholder, undeleted = live or the admin-removal status of the comment or its root post, unindexed = hidden. +- `visible` = no applicable state is hidden, plus collection guards, plus: blocks (`n.actor_did IS NULL OR NOT EXISTS user_blocks` either direction; groups use the per-voter rule), the alive rule for upvote groups, and disabled reasons (`NOT EXISTS ... n.reason = ANY(ps.disabled_reasons)`, NULL-safe). +- Admin removals (`moderation_decisions`, kind `removal`, active) are one scalar aggregate per reference (`moderatedState`): any instance scope gives `removedByServerAdmin`, community scope only gives `removedByModerator`, so several decisions never multiply rows. `ReferenceStates` (the write gate) applies the same precedence and also classifies a removed URI with no indexed row. +- Attacker-chosen URIs can store phantom subjects and roots, so every reference check is a positive EXISTS/JOIN, never "NOT EXISTS a deleted row". + +### Read path: unread count, updateSeen, preferences + +- `CountUnread`: unread bound written as `n.sort_at > COALESCE((SELECT seen_at ...), '-infinity')` with no `notification_state` join; `LIMIT 1` when `seen_at` is NULL (only the newest visible row is unread), else 101. Keep the bound in this form: an OR'd or joined `seen_at` scans the whole read history. `TestNotificationUnreadCount_SeenAtBoundsRecipientIndexScan` pins it. +- `UpdateSeen`: `INSERT ... LEAST($2, NOW()) ON CONFLICT DO UPDATE SET seen_at = GREATEST(old, LEAST(new, NOW()))`, `$2` sent in UTC (Postgres rejects offsets beyond ±15:59). Never writes `disabled_reasons`. The read sweep trusts `seen_at`, so the future clamp is load-bearing. Handler parses with Indigo `syntax.ParseDatetimeTime`. +- Preferences: `GetPreferences` returns all true without a state row; `PutPreferences` is one upsert that merges named reasons, drops NULLs, never writes `seen_at`, and returns the full object. The `[]string{}` literals are load-bearing (nil would bind NULL). Handler uses `xrpc.DecodeJSON(..., reqbody.LimitTiny, ...)`. +- A caller with no `users` row: 23503 on `notification_state_did_fkey` → `notifications.ErrAccountNotIndexed` → 400 `AccountNotIndexed` (both `putPreferences` and `updateSeen`). + +### Read path: listNotifications + +- SQL (`notification_list.go`): one REPEATABLE READ read-only tx: `seen_at`, then the page, then the upvote aggregate (only when the page has upvote rows). Page WHERE is `CASE WHEN (visible) THEN true ELSE false END` plus keyset `(n.sort_at, n.id) < ($2, $3)`, `ORDER BY n.sort_at DESC, n.id DESC LIMIT limit+1`. The CASE is a planner estimate barrier; keep it (`TestNotificationList_RecipientSortIndexWithMostlyBlocked` catches a regression). Every reason is listed. +- `isRead`: `sort_at <= seen_at`, or with NULL `seen_at`, every row except the newest visible row of any reason. +- Per-reference state and current CID: `ListedNotification.RootPost/Subject/Record` (`ListedReference{State, CID}`). Subject CID = `COALESCE(subject_post.cid, subject_comment.cid)`; record = post or comment. `listedReference()` errors on NULL or unknown state. Not-applicable positions are skipped by reason: mentions have no subject, upvotes have no record and no actor. +- Upvote aggregate (`buildListNotificationUpvotesSQL`): per page subject, `COUNT(*)` of qualifying votes + `bridgedUpvoteTotalSQL`, and up to 3 voters ordered `indexed_at DESC, id DESC`. +- Cursor (`cursor.go`): RawURL base64 of `|`; encoded length > 96 rejected. A malformed cursor wraps `ErrInvalidCursor`; the handler returns a fixed `InvalidCursor` 400 message and never echoes or logs the input. +- Hydration (`list_service.go`): one `repo.List`, then at most one each of `GetByDIDs` (authors and voters; unindexed → DID-only `profileView`), `GetViewsByURIs(..., "")` (live post roots, post subjects, post-mention records; anonymous viewer), `GetByURIsBatch` (live comments). `GetByURIsBatch` returns deleted comments, so a live-classified comment with `DeletedAt` set is treated as missing. +- A live reference that is missing at hydration omits the row (never half-hydrated). One `WarnContext` per request, "notifications: omitted unhydratable rows", with counts by reason and cause, never identifiers. +- Rendering: placeholders carry `status` and no title, preview, excerpt, community, labels or thumbnail; strong-ref CIDs come from `List`. `rootPost` is always emitted. Times are `UTC().Format(time.RFC3339Nano)`; clients echo the largest `sortAt` into `updateSeen`, so never truncate. Empty pages render `[]`. A page can hold fewer than `limit` rows (even zero) while `cursor` is present. +- Text (`notificationPostText`): title unless blank (whitespace-only), else body excerpt (140 graphemes, `notificationExcerpt`), except image posts (stored `images` or `images#view` embed), which get no body text. `rootPost.title` is omitted when blank and never falls back to the body. Post-mention `record.excerpt` = title, else body excerpt. +- Labels: post views from `Record["labels"]`, comments from parsed `ContentLabels`; values with `val` > 128 bytes dropped, first 10 kept; key omitted when empty. A live comment reference whose `ContentLabels` does not parse omits the whole row (counted as `malformed_labels`). +- Thumbnails: `posts.PreviewThumbnail(view)` (`internal/core/posts/notification_thumbnail.go`) is the only source. Empty when the image proxy is disabled or its base (`CDNURL` over `ProxyBaseURL`) is not an absolute http(s) URL; only `social.coves.embed.external` with a blob `external.thumb` or `social.coves.embed.images` with a blob `images[0].image`; CID checked (≤256 chars, `cid.Decode`, `imageproxy.ValidateCID`); the projected URL must carry the `#view` stamp and pass `syntax.ParseURI`. It mutates `view.Embed`, so call it once per view before anything else projects it. Alt from `images[0].alt`, bounded to 1,000 graphemes / 10,000 bytes, omitted when blank. Comments and video get none. + +### Lexicons (`internal/atproto/lexicon/social/coves/notification/`) + +- `defs.json`: `reason` (open, knownValues `postReply`, `commentReply`, `mention`, `upvote`); `notificationView` with `reason`, `sortAt`, `isRead`, required `rootPost`, optional `subject`, `record`, `author`, `upvoteCount`, `recentUpvoters` (max 3); `rootPost`/`subject`/`record` each carry `uri`, `cid`, `status` (knownValues `deleted`, `removedByModerator`, `removedByServerAdmin`), `labels` (`com.atproto.label.defs#selfLabels`), `thumbnail`, `thumbnailAlt`; `preferences`. +- `getUnreadCount.json` (count 0–101), `listNotifications.json` (error `InvalidCursor`), `updateSeen.json` and `putPreferences.json` (error `AccountNotIndexed`), `getPreferences.json`. +- `TestNotificationListLexicon_Contract` (`tests/lexicon_notification_list_test.go`) checks view field names against the lexicon; a new view field needs a lexicon property. + +### Wiring (`cmd/server`) + +- `wiring.go` `buildRepositories`: `a.notificationRepo` (with `WithBridgedUpvoteTotals()` when trusted hosts are set) and `a.notificationRetentionSweeper` (type assertion, checked at compile time by `var _` assertions in the postgres package). Later: `notificationService`, `notificationListService` (from `userRepo`, `postRepo`, `commentRepo`), `preferencesService`, all by unchecked assertions on the same repo. +- `routes.go`: `RegisterNotificationRoutes` (getUnreadCount, updateSeen), `RegisterNotificationListRoutes`, `RegisterNotificationPreferenceRoutes` from `internal/api/routes/notification.go`. +- T2 contracts (`tests/e2e/notification_contract_test.go`) cover replies, mentions (comment and postv2), upvotes (including self-upvote never notifying) and mark-seen. Bridged totals have no T2: the hermetic stack has no bridge. + +## 4. Operations + +- **Migrations**: `053_notifications.sql`, `054_votes_upvote_history_index.sql`, `055_notification_public_post_withdrawals.sql`. 054 builds an index `CONCURRENTLY` outside a transaction on the large `votes` table; if interrupted it leaves an INVALID index that must be dropped by hand (`DROP INDEX CONCURRENTLY idx_votes_voter_subject_upvotes`) before retrying, since there is no `IF NOT EXISTS`. +- **Activation**: `notification_activation.activated_at` is set when 053 runs. Records created before it never notify; there is no feature flag. +- **Lexicons**: the `social.coves.notification.*` schemas are published in their own release step, `docs/LEXICON_PUBLISHING.md` section 4. +- **Routing**: no Caddy change. The `@appview` matcher already sends every `/xrpc/*` path to the AppView. +- **Bridged totals**: notify and count only when `TRUSTED_BRIDGE_PDS_HOSTS` is non-empty. The setting is read at boot; changing it needs a restart. Turning it off makes the hourly sweep delete bridged-only groups for good. +- **Thumbnails**: require the image proxy enabled (`IMAGE_PROXY_ENABLED`) with an absolute `IMAGE_PROXY_BASE_URL` or `IMAGE_PROXY_CDN_URL`. With the proxy off (dev, or production with `AllowUnproxiedMedia`) notifications carry no thumbnails; feeds are unchanged. +- **Retention job** starts with the server, hourly. Watch for `notification retention sweep failed`. On shutdown, a mid-statement cancel can surface as SQLSTATE 57014 and log one spurious error. +- **Clients**: render `status` placeholders, blur by `labels`, page while `cursor` is present (pages may be short), echo the newest `sortAt` verbatim into `updateSeen`. + +## 5. Known limitations + +Accepted for now; changing any of them is a product call. + +- Community `contentWarnings` are not applied to previews (postponed until community NSFW lands in the NSFW system). +- No video thumbnails. +- Whitespace-only titles count as no title only in notification previews. The posts service accepts whitespace-only titles, and feed, thread and `post.get` views return them verbatim. +- A removal that lands before the root post is indexed leaves a `removed` admission row with no posts row, so comments on that post are not gated at write time ("unindexed is live"). +- A zero or epoch `seenAt` turns a never-seen user's state into "everything unread". +- Feeds, `post.get` and comment views serve forged `#view` embeds and string thumbnails from firehose records. Notification thumbnails reject these; other read paths do not. diff --git a/docs/PRD_NOTIFICATIONS.md b/docs/PRD_NOTIFICATIONS.md new file mode 100644 index 0000000..80ed3ae --- /dev/null +++ b/docs/PRD_NOTIFICATIONS.md @@ -0,0 +1,498 @@ +# In-App Notifications PRD + +Status: Backend implemented 2026-10 (see [NOTIFICATIONS_IMPLEMENTATION.md](NOTIFICATIONS_IMPLEMENTATION.md) for decisions made during the build). Draft 3 (2026-10-01) added decision 7; decisions 8 and 9 were added 2026-10-02 to 2026-10-04. +Repos: `coves` (backend + lexicons), `coves-frontend` (SvelteKit), `coves-mobile` (Flutter) + +## Summary + +Tell users when someone replies to their post, replies to their comment, @mentions them, or upvotes their content. Notifications appear in-app on web and mobile: a list view plus an unread badge. Push delivery is out of scope. + +Nothing exists today: +- **Mobile:** a placeholder `NotificationsScreen` is wired into the bottom nav as tab 3 (`lib/screens/home/notifications_screen.dart`). +- **Frontend:** only leftover Photon "inbox" i18n strings, and no route. +- **Backend:** no table, service, or lexicon. The only "notifier" is `internal/core/adminreports/notifier.go`, the operator Telegram alert, which is unrelated. + +## Decisions made + +Confirmed by the product owner on 2026-09-28: + +1. **Delivery:** in-app only. No push of any kind. +2. **Events:** replies to my post, replies to my comment, @mentions, upvotes on my posts and comments. Downvotes never notify. +3. **Upvotes are grouped per item on the server:** one notification per post or comment ("alice and 12 others upvoted your comment"). A new visible upvote moves the row to the top and marks it unread. +4. **Per-type settings:** each of the four reasons can be turned off. +5. **Activity time is the AppView's index time.** This deliberately differs from Bluesky, which uses `least(createdAt, indexedAt)`. Coves regularly indexes records late: out-of-order references are dead-lettered and redriven on a timer, external PDSes lag, and Jetstream rewinds its cursor on reconnect. Under Bluesky's rule, a late reply would arrive already read. +6. **Seen state follows Bluesky's model, with two changes.** Like Bluesky, each user has one monotonic `seen_at`. Unlike Bluesky, the client sends the newest `sortAt` it displayed rather than its last poll time, and the server clamps `seenAt` to server time. + +Confirmed by the product owner on 2026-10-01: + +7. **Notifications about deleted or moderator-removed content stay visible as placeholders, and that content gets no new notifications.** + + | State of the post or comment | New notifications | Existing notifications | + |---|---|---| + | Comment deleted by its author | none | stay; the comment's excerpt is replaced by a placeholder ("[Deleted Comment]") | + | Postv2 post deleted by its author (firehose tombstone) while publicly visible | none | stay; title and excerpt replaced by "[Deleted Post]" | + | Postv2 post removed by a moderator (admission `status = 'removed'`) while publicly visible | none | stay; title and excerpt replaced by "[Removed by moderator]" | + | Post pending, rejected, awaiting re-acceptance, missing its admission row, or CID-mismatched (never publicly visible in its current form) | written, hidden | hidden | + | Post deleted or removed while not publicly visible; postv2 soft-deleted only by `compensateAuthorDelete`; legacy (pre-postv2) post soft-deleted or removed | none | hidden | + | Account erased | n/a | deleted (erasure is a privacy deletion) | + + - "No new notifications" is enforced at write time (see Write-time rules). Restoring a removed post does not backfill notifications for activity during the removal. + - Author deletes no longer delete notification rows. A re-created comment's surviving mention rows count toward its 10-mention cap, so the cap carries over. + - An upvote group on a deleted or removed item keeps its count and stays listed, but never bumps again. + - Placeholder rows are visible rows: they are listed, count toward `getUnreadCount`, and follow retention like any other row. Blocks and preferences still hide them. + - A post shows a placeholder only if it was publicly visible in its current form at the moment it was deleted or removed. Existing columns cannot tell this afterwards, so the deleting or removing transaction records it (see Public-withdrawal markers). A post deleted or removed while pending or rejected stays hidden. + - Legacy posts (`social.coves.community.post`, about 99% aggregator posts) are either live or hidden. No path records a marker for them, so a legacy post soft-deleted by the rematerializer or the legacy delete path, or removed by a moderator, is hidden. Live legacy posts are listed as before. + - The API returns a status code, not placeholder text: each referenced view (`rootPost`, `subject`, `record`) carries a `status` and omits its title and excerpt. Clients render the bracket text. A placeholder row still opens the post thread, where the existing deleted and removed states render. + +Confirmed by the product owner on 2026-10-02: + +8. **Bridged upvote totals (Lemmy, via Tidepool) join the upvote notification, without names.** The bridged-vote poller stores each item's fediverse upvote total in `bridged_upvote_count`. These totals have no voters attached. + - `upvoteCount` = qualifying native upvotes + the item's bridged upvote total. `recentUpvoters` lists only native voters. An item with only bridged upvotes shows a count and no names ("5 people upvoted your post"). A mixed item shows "alice, bob and 3 others". Bridged downvotes are ignored. + - **Bump.** Each poll that finds the bridged total above the item's high-water mark (the highest bridged total it has ever reached, stored on the post or comment) moves the group to the top, marks it unread, and raises the mark. That is one bump per new high, however large the increase. A decrease, or a rise back up to the mark, only changes the count and never bumps: 5 → 3 → 5 → 4 → 5 notifies nothing, and 6 then notifies once. + - **Launch baseline.** Totals already stored when this ships notify no one. Only increases observed after launch create or bump a group, and the count always includes the full total. + - **No age gate.** The activation cutoff and the 7-day freshness gate do not apply to bridged totals, and neither does any check on the bridge's `asOf` age. The poller observes increases live, so any increase observed after launch bumps, even on an item created long before (product owner, 2026-10-03). + - **Empty.** A group with no qualifying native upvote and a bridged total of 0 is deleted, the same rule as native delete-if-empty. + - **Gates.** Every recipient gate applies: erased, aggregator, bridge-PDS and unindexed recipients get nothing. The self and block rules need an actor, so they do not apply. A deleted or removed item keeps its group and count but never bumps (decision 7). + - **Trusted bridge only.** Only a bridge listed in `TRUSTED_BRIDGE_PDS_HOSTS` can create or bump a group. That is the poller's own and only dial list. With the setting empty, bridged totals do not count toward `upvoteCount`, do not keep a group alive, and never create or bump one. + - **The gate is the whole setting, not per host** (product owner, 2026-10-03). While any bridge is configured, every stored bridged total counts and keeps its group alive, including totals last written by a bridge that has since been removed from the list. A removed bridge's totals never bump again, because the poller stops dialing it. There is no per-host provenance column. + - Only the poller's path for natively authored content can reach an eligible recipient. Totals on bridge-authored records (`bridgedStats` via Jetstream) belong to bridge-PDS accounts, which are never notified. +9. **Previews carry their author's self-labels and a post thumbnail; blank titles count as none; upvoters are named in indexing order** (product owner, 2026-10-03). + - **Labels.** Each preview (`rootPost`, `subject`, `record`) carries the self-labels its own author applied (`nsfw`, `spoiler`, `violence`), as feeds serve `record.labels`. The app blurs per the viewer's setting. The server hides nothing, and labels are not inherited between positions. A reply under an NSFW post carries the label on `rootPost` only, so the app can blur the whole notification. Unknown values pass through as in feeds. To stay valid against `com.atproto.label.defs#selfLabels`, values over 128 bytes are dropped and at most the first 10 are kept. For a list that already meets those bounds, a notification blurs exactly when the feed does; for an oversized list the notification's labels are bounded and can differ from the feed's (ten unknown values followed by `nsfw` lose the `nsfw`). Community `contentWarnings` are not applied; they are postponed until community NSFW is part of the NSFW system. + - **Thumbnail.** A post preview carries an optional `thumbnail`: the link card's thumbnail for a link post, or the first image for an image post. The URL is built by the same image-proxy projection the feeds use, never from a raw blob reference, and only from an image the stored record actually carries as a blob with a valid CID. The URL must be an absolute image-proxy URL that passes the lexicon's URI check. An embed already claiming the served (`#view`) shape, a URL string inside the record, or a malformed or oversized blob CID never produces a thumbnail. The root post line (`rootPost`) carries its post's thumbnail too. Comments, video posts (for now) and quoted posts have none, and neither do links to image hosts that offer a gallery but no link-card thumbnail. Deployments running without the image proxy (development, or production with `AllowUnproxiedMedia`) show no notification thumbnails; their feeds are unchanged. + - **Alt text.** An image post's thumbnail carries the first image's alt text (`thumbnailAlt`), so screen readers can announce something. It is set only when the thumbnail is, omitted when the alt is empty or whitespace-only, and cut to the images lexicon's alt limits (1,000 grapheme clusters, 10,000 bytes), so a hostile record cannot make the page fail validation. A link card's thumbnail has no alt text, because the link embed has none for its image. + - **Blank titles.** A whitespace-only title counts as no title, so the preview falls back to the body excerpt, and `rootPost` omits its title. Exception: an image post with no title, or a blank one, shows only its thumbnail and no body text. An image post with a real title shows the title and the thumbnail. An untitled image post whose image can't be shown displays no text. + - **Upvoter order.** `recentUpvoters` is ordered by the existing AppView indexing timestamp, descending; ties by higher vote ID. It is not ordered by the vote record's `createdAt`. Someone who removes an upvote and upvotes again is named first, but the notification does not move to the top, because a voter bumps an item's upvote notification only with their first upvote on it. + - Deleted and removed references (decision 7) carry no labels, no thumbnail and no alt text. + +## Non-goals + +- Push notifications and email digests. +- Community-level events: moderator actions, new posts in subscribed communities, community (`!`) mentions, bans. +- Notifying users who read through another AppView. Notifications are AppView-local derived state, as in Bluesky. +- Backfill. Notifications start at the activation time (below). +- Naming bridged voters. Bridged upvotes arrive as per-item totals without actors, so they add to an upvote group's count and bump it (decision 8), but never appear in `recentUpvoters`. Notifications from the Jetstream `bridgedStats` record channel are also out, because its items belong to bridge-PDS accounts. +- Real-time transport. Clients poll. +- An authenticated T2 harness. It is a separate prerequisite; see Testing. +- Community content warnings in notification previews. They are postponed until community NSFW is part of the NSFW system (decision 9). + +## Prerequisite + +T2 cannot mint a sealed AppView session today (`docs/TEST_ARCHITECTURE.md` §3.4b and §6, items 1–2). Every endpoint in this PRD requires one. That work is being tackled separately. Until it lands, authenticated endpoint behaviour is proven at T1 (see Testing). + +## Architecture + +### Signal sources + +All four events already flow through existing Jetstream consumers, and bridged upvote totals (decision 8) through the existing bridged-vote poller. No new wanted collections are needed, so `cmd/contract-manifest` is unaffected. + +| Reason | Source record | Recipient | Hook points | +|---|---|---|---| +| `postReply` | Comment with `parent_uri == root_uri` | Post author | `createComment` → `indexCommentAndUpdateCounts`, including its resurrection branch | +| `commentReply` | Comment whose parent is a comment | Parent comment's repo DID | Same as `postReply` | +| `mention` | Comment or postv2 post with a user `#mention` facet | Each mentioned user DID | Comment: create path, plus `updateComment` for added mentions. Post: `insertAuthorPost` (covers Jetstream and the acceptance direct-fetch path at `authorpost.go:1281`), plus `applyPostContentUpdate` for added mentions | +| `upvote` | Vote row, any direction, create or replacement | Subject author | `indexVoteAndUpdateCounts`, including its stale-vote replacement branch, and `deleteVote` | +| `upvote` (bridged total) | A poll that changes a native item's stored `bridged_upvote_count` | Subject author | `BridgedVotesRepository.ApplyAggregate`, driven by the bridged-vote poller (`internal/core/bridgedvotes`) | + +**Post author resolution.** A `social.coves.community.postv2` URI's repo DID is its author. Legacy `social.coves.community.post` events are dropped by `PostEventConsumer`, but legacy rows still exist, so replies to legacy posts resolve the author from `posts.author_did`. If that row is missing, the event produces no notification. Mentions are fanned out only from postv2 posts and comments. + +**Community mentions.** A facet whose DID resolves to a community is ignored. + +### Transactionality + +A notification mutation runs inside the transaction of the index mutation that caused it, and only when that mutation actually applied. A rev-gated no-op, an unchanged-CID update, or a rejected event produces no notification change. A failure in notification code fails the transaction and uses the consumer's existing retry and dead-letter path. + +The hook functions above commit at several points, including early commits in the vote consumer's branches and the comment resurrection branch. Each commit point that applies a qualifying mutation must include the notification mutation before it commits. The consumers call `notifications.Fanout(...)` for pure intent computation and `Repository.ApplyTx(ctx, tx, intents)` for the writes. They never import the service. + +The poller's `ApplyAggregate` follows the same rule. In one READ COMMITTED transaction it locks the item row, reads the stored total, applies the guarded aggregate update, and then writes the group change. A stale `asOf` that loses the guard changes nothing. A failed notification write rolls back the aggregate, and the batch is retried on the next sweep. + +### Activation cutoff + +The migration writes a single row, `notification_activation(activated_at = NOW())`. Fan-out ignores any source record whose `createdAt` is earlier than `activated_at`. This stops redrives and first-time indexing of pre-deploy activity from producing notifications, even when that activity is recent. + +**Bridged-total exception (decision 8).** A bridged-total increase observed by the poller is not subject to the activation cutoff. It has no source record, and neither the item's `createdAt` nor the bridge's `asOf` is compared with `activated_at`. The launch baseline is the total stored when this ships: only a polled total greater than the stored total is an increase. The recipient rules and the deleted/removed rule still apply (Write-time rules). + +### Freshness gate + +After activation, fan-out also ignores source records whose `createdAt` is more than 7 days before index time. This bounds cursor rewinds, redrives, and first-time indexing of an older repo. + +**Bridged-total exception (decision 8).** A bridged-total increase observed by the poller is not subject to the freshness gate. It bumps whatever the item's age and whatever the age of the bridge's `asOf`, as long as that `asOf` passes the stored-`asOf` guard (Transactionality). The recipient rules and the deleted/removed rule still apply (Write-time rules). + +Mentions added by an edit are gated on the edit's index time, not the record's original `createdAt`. A new mention on an old record still notifies. + +### Write-time rules + +A notification row is not written, and an upvote group is not bumped, when: + +- The actor is the recipient. This covers the automatic author upvote on every create. +- The recipient is ineligible: + - not in `users`, or in `deleted_accounts`; + - an aggregator (`aggregators.did`); + - hosted on a bridge PDS according to `BridgeTrust`. The comment and post consumers already carry `BridgeTrust`. The vote consumer gains a `WithVoteBridgeTrust` option. +- The actor is erased (`deleted_accounts`). The comment consumer has no erasure gate today, so this check is new. +- Either party blocks the other at write time. A block created later is enforced at read time, but it does not undo a bump that already happened. +- For `mention`: the recipient already gets a `postReply` or `commentReply` for the same record. One record produces at most one notification per recipient. +- For a comment whose parent comment author is also the post author: `commentReply` only. +- For mention edits: the new facet's mentioned DIDs are compared with the stored facets. Only DIDs that were not previously mentioned notify. Removed mentions do not retract notifications. +- **Deleted or removed content (decision 7).** The subject, the source's parent or the root post is author-deleted or moderator-removed, or the subject or parent comment is deleted. A comment's parent can be a post other than its root, so the comment gate checks the root and the parent separately. This is read inside the writing transaction from existing columns: + - a deleted comment has `comments.deleted_at` set; + - a deleted post has `posts.deleted_at` set, whatever its collection; + - a removed post's own-community admission row (the `visiblePostsPredicate` join key) has `status = 'removed'`. + + The gate does not read the public-withdrawal markers: a post deleted or removed while pending gets no new rows either. + + It applies to every fan-out path: comment create, resurrection and edit mentions, post create and edit mentions (a removal can arrive before the post is indexed), and the upvote bump on create and replacement. Group maintenance is not gated. It also applies to a bridged-total increase (decision 8): a withdrawn subject or root keeps its group and count but is never bumped. Pending, rejected and other never-public posts are not gated; their rows are written and hidden at read time. Restoring a removed post does not backfill. + +For a bridged-total increase (decision 8), only the recipient rules and the deleted/removed rule apply. There is no actor, so the self, erased-actor and block rules have nothing to check. The activation cutoff and the freshness gate are the only gates it skips (see their bridged-total exceptions). + +### Upvote group maintenance + +Maintenance is separate from notification production. It runs after **every** committed change to the vote set of a subject: create, stale-vote replacement (including a replacement by a downvote), and delete. + +- The changed vote is a live `up` from a qualifying voter (not self, eligible, not blocked) on a subject that is not deleted or removed: upsert the group and set `sort_at = GREATEST(sort_at, now)`. A group on a deleted or removed item keeps its count but never bumps. +- Any other change (downvote replacement, retraction): if no live qualifying upvote remains on the subject and its bridged upvote total is 0, delete the group. Never move `sort_at` backwards. +- **Bridged totals (decision 8).** These run in the poller's aggregate transaction, comparing the new total with the stored one: + - An increase on an item that is not deleted or removed, for an eligible recipient, upserts the group and sets `sort_at = GREATEST(sort_at, now)` only when the new total exceeds the item's high-water mark. It bumps once per new high. + - A decrease never bumps. It deletes the group by the rule above, so only when no qualifying native upvote remains and the total is now 0. + - An unchanged total, and a stale `asOf`, change nothing. + - "Alive" (at least one live qualifying upvote, or a bridged total above 0) is one SQL helper. Delete-if-empty, the empty-group sweep and the read-time rule all use it. + - With `TRUSTED_BRIDGE_PDS_HOSTS` empty, the helper ignores bridged totals. Otherwise it counts every stored total, whichever bridge wrote it. It never filters by host. + +The consumer does not handle `update` operations or URI conflicts. A "direction flip" therefore only exists as the replacement branch or as delete plus create, and must be tested through those paths. + +### Read-time rules + +Applied identically by `listNotifications` and `getUnreadCount`, in SQL before pagination: + +- **Post visibility (decision 7).** The subject post, the source post, and the root post of any comment are each classified using the existing admission-aware predicate (`internal/db/postgres/post_visibility.go`) bound to the **anonymous viewer**, not the recipient. The first matching state applies: + 1. **Removed by moderator:** the post's own-community admission has `status = 'removed'`, and the post has a `communityWithdrawal` marker (see Public-withdrawal markers). Listed as a placeholder. + 2. **Live:** not deleted, and the predicate passes. Listed normally. + 3. **Deleted:** `deleted_at` is set, and the post has an `authorDelete` marker. Listed as a placeholder. + 4. **Hidden:** anything else, including pending, rejected, awaiting re-acceptance, missing-admission and CID-mismatched postv2 posts (even from their own author), posts deleted or removed in one of those states, postv2 posts soft-deleted only by `compensateAuthorDelete`, deleted or removed legacy posts, and posts that are not indexed. The row is not listed or counted. + - A pending post that mentions someone does not leak its preview. + - If a pending post is later accepted, its rows become visible with their original `sort_at`, so they may already be marked read. This is accepted. + - Rows written before a removal stay as placeholders. Write-time rules stop new rows during the removal, so third-party writes straight to the PDS produce nothing new. +- **Comment visibility (decision 7).** Source and subject comments must be indexed. A comment with `deleted_at` set is listed as a placeholder. +- **Blocks.** Neither party blocks the other (`user_blocks`, in both directions). For upvote groups this applies per voter. +- **Preferences.** The reason is not in `notification_state.disabled_reasons`. +- **Upvote groups.** At least one live qualifying upvote exists, checked with `EXISTS`, or the item's bridged upvote total is above 0 (decision 8; ignored when `TRUSTED_BRIDGE_PDS_HOSTS` is empty, never filtered by host). A group whose voters are all hidden and whose bridged total is 0 is not listed and not counted. + +### Erasure + +`postgresUserRepo.Delete` (`internal/db/postgres/user_repo.go:278`) runs erasure in one transaction. It hard-deletes the user's comments, votes, posts, and `users` row. It gains two steps: + +1. `DELETE FROM notifications WHERE recipient_did = $1 OR actor_did = $1`, plus deletion of the user's `notification_state` row and of the `notification_public_post_withdrawals` markers for the user's posts (both kinds). +2. Upvote groups this voter contributed to are left in place. The read-time `EXISTS` rule hides any that lose their last voter and have no bridged total. A periodic sweep deletes groups with no qualifying voters and a bridged total of 0. + +**Recipient race.** `notifications.recipient_did` has a foreign key to `users(did) ON DELETE CASCADE`. The fan-out insert takes a `KEY SHARE` lock on the users row, which blocks until an erasure that is deleting it commits, and then fails. Erasure therefore can't interleave with a recipient insert. + +**Actor race.** Actors have no foreign key, because votes and comments arrive before their authors are indexed. Fan-out takes `pg_advisory_xact_lock_shared(hashtext('erasure:' || actor_did))` and then checks `deleted_accounts`. Erasure takes the exclusive form of the same lock before inserting the `deleted_accounts` marker. + +### Data model + +The migration takes the next free number at implementation time (it landed as 053), with Goose Up and Down sections. + +```sql +CREATE TABLE notification_activation ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + activated_at TIMESTAMPTZ NOT NULL +); +INSERT INTO notification_activation (activated_at) VALUES (NOW()); + +CREATE TABLE notifications ( + id BIGSERIAL PRIMARY KEY, + recipient_did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + reason TEXT NOT NULL CHECK (reason IN ('postReply','commentReply','mention','upvote')), + record_uri TEXT, -- the reply or mentioning record; NULL for upvote groups + record_cid TEXT, + actor_did TEXT, -- NULL for upvote groups + subject_uri TEXT, -- recipient's own post/comment; NULL for mention + root_post_uri TEXT NOT NULL, -- navigation target for every reason + record_created_at TIMESTAMPTZ, -- display only ("2h ago"); NULL for upvote groups + sort_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- index time; bumped by new upvotes + CHECK ( + (reason = 'upvote' AND record_uri IS NULL AND record_cid IS NULL AND actor_did IS NULL + AND subject_uri IS NOT NULL) + OR (reason IN ('postReply','commentReply') AND record_uri IS NOT NULL AND record_cid IS NOT NULL + AND actor_did IS NOT NULL AND subject_uri IS NOT NULL) + OR (reason = 'mention' AND record_uri IS NOT NULL AND record_cid IS NOT NULL + AND actor_did IS NOT NULL AND subject_uri IS NULL) + ) +); + +CREATE UNIQUE INDEX uq_notifications_record + ON notifications (recipient_did, reason, record_uri) WHERE reason <> 'upvote'; +CREATE UNIQUE INDEX uq_notifications_upvote_group + ON notifications (recipient_did, subject_uri) WHERE reason = 'upvote'; +CREATE INDEX idx_notifications_recipient_sort + ON notifications (recipient_did, sort_at DESC, id DESC); +CREATE INDEX idx_notifications_record ON notifications (record_uri) WHERE record_uri IS NOT NULL; +CREATE INDEX idx_notifications_actor ON notifications (actor_did) WHERE actor_did IS NOT NULL; + +CREATE TABLE notification_state ( + did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, + seen_at TIMESTAMPTZ, -- NULL: never marked seen + disabled_reasons TEXT[] NOT NULL DEFAULT '{}' +); +``` + +**Writes.** Record-keyed rows use `INSERT ... ON CONFLICT DO NOTHING`. Upvote groups use `ON CONFLICT DO UPDATE SET sort_at = GREATEST(notifications.sort_at, EXCLUDED.sort_at)`. Duplicate delivery, rev-gated replays, direct fetch, and redrive are therefore all idempotent. + +**`sort_at`** is `NOW()` at the moment the qualifying mutation is applied. Record `createdAt` never influences ordering or unread, so a future-dated record can't pin itself to the top and a late record isn't born read. + +**Upvote actors and counts** are not stored. They are derived at read time from live `votes` rows that pass the read-time rules, so they can't drift from the votes table. The count adds the item's `bridged_upvote_count` (decision 8), read from the posts or comments row. The actors are native voters only. Upvote groups already store no actor (the CHECK above), so bridged-only groups need no schema change. + +**Author deletion keeps rows (decision 7).** When a comment or postv2 post is deleted by its author, `deleteComment` and `tombstoneAuthorPost` keep every notification row, including on a zero-row soft delete. The read-time rules show those rows as placeholders when the post has an `authorDelete` marker, and hide them otherwise. A post soft-deleted outside the firehose by `compensateAuthorDelete` also keeps its rows, so that path needs no notification housekeeping. + +- A comment re-created at the same URI (resurrection) keeps its rows. Recipients who already hold a row are not notified again. The surviving mention rows count toward the 10-mention cap. +- A resurrection under a different parent, in the same transaction: + - deletes the record's reply row whose subject is no longer the parent; + - deletes the new reply recipient's mention row, because the reply takes precedence; + - moves the remaining rows to the new root when the root changed. + +**Public-withdrawal markers (decision 7).** "Publicly visible at the moment it was deleted or removed" cannot be read from existing columns afterwards: +- For a hosted community, `tombstoneAuthorPost` withdraws the acceptance after the soft delete, and `ApplyAcceptanceDelete` returns the admission to `pending` and clears `accepted_cid`. +- `ApplyRemoval` clears the acceptance columns, and it inserts a `removed` row for a pre-emptive removal of a post that was never accepted. + +So the transaction that withdraws a post records whether the post was publicly visible just before it, using `visiblePostsPredicate(anonymousViewerSQL)` (reused, never copied). The next migration adds: + +```sql +CREATE TABLE notification_public_post_withdrawals ( + post_uri TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('authorDelete', 'communityWithdrawal')), + community_rev TEXT, -- the community event's rev; NULL for authorDelete + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (post_uri, kind), + CHECK ((kind = 'authorDelete') = (community_rev IS NULL)) +); +``` + +Markers are written only for postv2 posts. A row means "this post was publicly visible just before this withdrawal applied". There is no foreign key, matching `notifications.root_post_uri`. + +- **`authorDelete`:** written by the tombstone transaction after the soft delete and before `withdrawAcceptance`, on the applied and the zero-row branch alike, when the post passes the predicate. This writer does not check `deleted_at` (the predicate does not read it), because the post is already soft-deleted when it runs. A post soft-deleted only by `compensateAuthorDelete`, whose acceptance is withdrawn before the firehose tombstone arrives, gets no marker and stays hidden (fail closed). +- **`communityWithdrawal`:** a moderator removal is the commit `{acceptance-delete, removal-put}` at one community rev, and the two halves can be applied in either order. Here "publicly visible" means not deleted (`p.deleted_at IS NULL`) and passing the predicate. The `deleted_at` clause matters: `compensateAuthorDelete` soft-deletes a post while its admission can stay accepted until the withdrawal arrives, and a removal in that window must not record a public withdrawal. Both writers record, in the same transaction as the admission write and only when the write applied: + - `ApplyAcceptanceDelete`: when the post was publicly visible before the write, upsert the marker with this event's rev. + - `ApplyRemoval`, when the prior own-community status was not `removed`: + - the post was publicly visible before the write: upsert the marker with this event's rev; + - it did not, and the marker's rev equals this removal's rev (the acceptance-delete half of the same commit came first): keep the marker; + - otherwise: delete the marker. + - The same-rev rule keeps the marker even if the post was soft-deleted between the two halves. + - `ApplyRemoval` on a post that is already `removed` leaves the marker alone. + - Only the post's own community counts: the posts row must have `community_did` equal to the event's community (the fork case writes nothing). + - A refused (stale) admission write changes no marker. + - "Before the write" is the committed admission row the write replaces. Each writer locks the admission row (`FOR UPDATE`) before deciding, so a concurrent admission write on the same row is seen once it commits. A row that did not exist when locked counts as not visible, even if a concurrent insert creates it. A failed marker write rolls back the admission write. +- **Lifting a removal** (`ApplyRemovalDelete`, or a restoring `ApplyAcceptance`) needs no marker change. The read rule requires `status = 'removed'`, so a lifted removal's marker is ignored, and the next removal recomputes it. A marker left by an unpaired acceptance deletion is ignored the same way. +- Erasure deletes the user's markers (`split_part(post_uri, '/', 3) = did`). + +### Unread and seen + +These follow Bluesky's `app.bsky.notification` model, with the two changes in decision 6. + +- **Unread:** `sort_at > seen_at`. Placeholder rows (decision 7) are visible rows and count. When `seen_at` is NULL, only the newest visible notification is unread. That matches Bluesky's first-load behaviour, so a new user isn't shown a huge count. +- **`updateSeen(seenAt)`:** the server stores `GREATEST(seen_at, LEAST(seenAt, NOW()))`. It never moves backwards and never runs ahead of server time. +- **Client watermark:** the client sends the largest `sortAt` among the notifications it displayed on page one. It never sends its own clock. +- **Accepted race:** a notification whose transaction stamped `sort_at` before the page-one query but committed after it gets a timestamp at or below the watermark. It is marked read without being shown. Consumer transactions last milliseconds, so this is documented and accepted. Bluesky has the same gap in a wider form. +- **Page state:** `listNotifications` returns the stored `seenAt`. Clients compute `isRead` for every later page against page one's `seenAt`, so the `updateSeen` issued after page one doesn't retroactively mark deeper pages read. This matches Bluesky's `feed.ts`. + +### Pagination + +Pagination is keyset on `(sort_at DESC, id DESC)` with an opaque cursor, base64 of the pair. + +Upvote groups can move upward. A group bumped above the cursor while the user scrolls is not shown on later pages. It appears at the top on the next page-one load, when it is unread new activity anyway. Clients reconcile by reloading page one on pull-to-refresh, and whenever the unread count increases. + +A malformed or undecodable cursor returns `InvalidCursor` (HTTP 400). Mobile applies its existing Discover recovery: replace page one once, then offer a manual retry. + +### Query cost + +- **`getUnreadCount`:** `SELECT count(*) FROM ( LIMIT 101)`. The result is reported as a number up to 100, with `101` meaning "100+". It never scans past 101 visible rows. +- **Read-time rules** are `EXISTS` and anti-joins in the same statement as the page query, so a page is never short because of filtering done after the fetch. +- **Bridge-PDS eligibility** is write-time only. There are no read-time network or config lookups. Whether bridged totals count (decision 8) is fixed when the repository is built at boot, from whether `TRUSTED_BRIDGE_PDS_HOSTS` is empty. It is one boolean per deployment, not a per-host check: while the setting is non-empty, every stored total counts, including one from a bridge since removed. +- **Hydration** batches profile and record lookups per page (`GetByDIDs`, `GetViewsByURIs`). It never queries per row. +- **Verification:** the implementation includes an `EXPLAIN` check on a T1 fixture with a large, mostly filtered history (for example 10k rows with 90% blocked), confirming the recipient/sort index is used. + +### Retention + +This adopts the policy Bluesky's AppView ran while it was Postgres-backed. That was PR bluesky-social/atproto#1893 (December 2023), removed in the February 2024 "AppView v2" rewrite. Bluesky's current public code has no retention at all, and production behaviour lives in a private dataplane. + +- **Read rows** are deleted once `sort_at < seen_at - 30 days`. A user who has never marked anything seen uses their newest `sort_at` as the reference instead. +- **Unread rows** are deleted only when a user has more than 500 unread, and only those more than 180 days older than the user's newest notification. Someone who stops opening the app keeps a bounded backlog. +- **Empty upvote groups**, meaning groups with no live qualifying voter left and a bridged upvote total of 0, are deleted by the same job. +- **Event-driven deletes** (erasure) happen at write time as described above, like Bluesky's `deleteActor`. Retention does not replace them. Author deletes keep their rows as placeholders (decision 7), and retention removes those rows like any other. + +**Scheduling.** A ticker goroutine started in `cmd/server`, alongside the redriver's `PruneDeadLetters` loop, runs every hour. Each statement deletes at most 10,000 rows, keyed by `id`, and the job loops until a batch comes back short, so it never holds a long lock. The thresholds are constants, not config. They can become config later if production data calls for it. + +**Volume.** Upvote groups collapse the largest notification category, which is one row per like in Bluesky, into one row per item. Reply volume per user should be comparable or higher in forum threads. The 30-day read window keeps the table at roughly a month of activity per user either way. + +### Package layout + +- `internal/core/notifications/`: + - `notification.go`: types, reasons, error sentinels. + - `interfaces.go`: `Repository` (with `ApplyTx(ctx, *sql.Tx, []Intent)`) and `Service`. + - `fanout.go`: pure intent computation and write-time rule evaluation, given injected lookups. + - `service.go`: list, count, seen, preferences, hydration. +- `internal/db/postgres/notification_repo.go`. + +## Lexicons + +New namespace: `social.coves.notification.*`. These follow the Lexicon Style Guide (camelCase `knownValues`) and are written so another forum AppView could implement them. + +| NSID | Type | Purpose | +|---|---|---| +| `listNotifications` | query | Params `limit` (1–100, default 50) and `cursor`. Returns `notifications[]`, `cursor`, `seenAt`. Errors: `InvalidCursor`. | +| `getUnreadCount` | query | Returns `count` (0–101; 101 means 100+). | +| `updateSeen` | procedure | Input `seenAt`. Stored as described under Unread and seen. | +| `getPreferences` | query | Returns `{ postReply, commentReply, mention, upvote }` booleans. | +| `putPreferences` | procedure | Partial update of the same four booleans. | +| `defs` | defs | `notificationView`, the `reason` knownValues, `preferences`. | + +All of these require auth, and the recipient is always the caller. + +**`notificationView`** fields: + +- `reason`: `postReply`, `commentReply`, `mention`, or `upvote`. The set is open. +- `sortAt`: the index time; this drives ordering and unread. +- `isRead` +- `rootPost`: strong ref, title, and community. Every reason carries this, so every row can be opened on its post. +- `subject` (`postReply`, `commentReply`, `upvote`): strong ref to the recipient's own post or comment, plus a preview. +- `record` (`postReply`, `commentReply`, `mention`): strong ref, text excerpt, and `createdAt` for display. +- `status` on `rootPost`, `subject` and `record` (decision 7): open knownValues `deleted` and `removedByModerator`, absent when the reference is live. When it is present, the view omits the title, community, preview or excerpt. A strong ref's CID is the referenced record's current CID. +- `labels` on `rootPost`, `subject` and `record` (decision 9): `com.atproto.label.defs#selfLabels`, the referenced record's author self-labels. Absent when there are none or when the reference has a `status`. +- `thumbnail` on `rootPost`, `subject` and `record` (decision 9): a post's link-card thumbnail or first image, as an absolute image-proxy URL. Never set for comments, for references with a `status`, or when the deployment runs without the image proxy. +- `thumbnailAlt` on `rootPost`, `subject` and `record` (decision 9): the alt text of an image post's first image, bounded like the images embed's `alt` (1,000 grapheme clusters, 10,000 bytes). Set only alongside an image post's `thumbnail`; absent for link-card thumbnails and when the alt is blank. +- `author` (`postReply`, `commentReply`, `mention`): `social.coves.actor.defs#profileView`. +- `upvoteCount` and `recentUpvoters` (`upvote` only): + - `upvoteCount` is the qualifying upvotes from accounts on this network plus the item's bridged upvote total from federated platforms (decision 8). + - `recentUpvoters` is up to 3 `#profileView` entries for the most recent qualifying voters on this network, ordered by the existing AppView indexing timestamp, descending; ties by higher vote ID. It may be empty when every counted upvote is bridged, so clients render "N people upvoted" without names. + +**Previews.** A post preview is its title. When the title is absent or whitespace-only, it falls back to a text excerpt, except for an image post, which has no text preview and shows its `thumbnail` instead. A comment preview is an excerpt truncated to 140 grapheme clusters. A deleted or removed reference has no preview; it carries `status` instead, and clients render "[Deleted Comment]", "[Deleted Post]" or "[Removed by moderator]". If a referenced record is not yet indexed, or a reference the list query saw as live is deleted before hydration, the row is omitted from that response. It is not returned half-hydrated. + +**Preferences** are AppView-local, not a PDS record, as with Bluesky's preferences. A user who switches AppViews loses them. That matches the notifications themselves. + +**Routing.** `/xrpc/*` is already covered by the Caddy `@appview` matcher and by the frontend proxy's path validation. The implementation confirms this with `caddy_allowlist_test.go` and the proxy tests. + +## Frontend (`coves-frontend`) + +- **Route `/notifications`** (auth required), with infinite scroll. + - Rows render per reason, with an unread highlight. + - A reference with `status` renders as "[Deleted Comment]", "[Deleted Post]" or "[Removed by moderator]" in place of its title or excerpt (decision 7). + - Replies and comment mentions open `rootPost` with the comment focused. Post mentions and upvote groups open the subject or `rootPost`. Placeholder rows navigate the same way, and the thread shows its existing deleted or removed state. + - A target that no longer exists shows the existing not-found state. +- **Nav bell with a badge.** + - Polls `getUnreadCount` every 30 seconds while `document.visibilityState` is visible, and once on focus. Stops when logged out. + - Shows "99+" when the count is above 99. + - When the count rises while `/notifications` is open, page one reloads. +- **Mark seen.** After page one renders, call `updateSeen` with the largest displayed `sortAt`. Compute `isRead` for later pages against page one's `seenAt`. Set the badge from the next `getUnreadCount` response, not by optimistically zeroing it. +- **Settings.** A "Notifications" section in `/settings`, using `ToggleSetting.svelte`, with four toggles backed by `get/putPreferences`. +- **API.** NSIDs go in `src/lib/api/coves/client.ts`, types in `types.ts`. +- **i18n.** Replace the Photon `inbox` keys in `en.json`. Other locales fall back to English. +- **States.** An empty state, a retryable error, and 401 handling through the existing session-expiration flow. + +## Mobile (`coves-mobile`) + +- **Notifications screen.** Replace the placeholder `NotificationsScreen` with a paginated list backed by `NotificationsProvider` (`lib/providers/`) and `NotificationService` (`lib/services/`, using `coves_http.dart` and `auth_interceptor.dart`). +- **Badge.** On the bell nav item in `main_shell_screen.dart` (`_buildNavItem(3, 'bell', …)`). It refreshes on `AppLifecycleState.resumed`, on tab switch, and every 30 seconds while foregrounded. +- **Mark seen.** Same rule as web. +- **Placeholders.** A reference with `status` renders as "[Deleted Comment]", "[Deleted Post]" or "[Removed by moderator]" in place of its title or excerpt (decision 7). Placeholder rows navigate like any other row. +- **Navigation.** Every row opens `PostDetailScreen` for `rootPost.uri`, passing `focusCommentUri` for replies, comment mentions, and comment upvotes. `FocusedThreadScreen` is not used directly, because it needs a hydrated thread and provider. `PostDetailScreen` already fetches and focuses a comment outside the loaded tree. A missing target shows the existing not-found state. +- **Settings.** Four switches on the existing settings or profile screen. +- **Refresh.** Pull-to-refresh replaces page one. `InvalidCursor` uses the Discover recovery rules. + +## Testing + +Tiers follow `docs/TEST_ARCHITECTURE.md`. Breadth goes at T0 and T1. + +**T0** (`internal/core/notifications`, fan-out with fake lookups): +- Every reason. +- Self-suppression, including the automatic author upvote. +- Mention deduplication against replies, and `commentReply` precedence. +- Community-DID mentions ignored. +- Edit fan-out: only newly added DIDs notify, and unchanged facets do not. +- The activation and 7-day gates at their boundaries, including old record with a new mention, and future-dated `createdAt`. +- The deleted/removed gate on every fan-out: no intents, and no upvote bump, when a referenced post is deleted or removed or a referenced comment is deleted. +- Cursor encode/decode, and tampered-cursor rejection. + +**T1** (Postgres): +- **Idempotency.** The same comment, post, direct-fetch post, or vote applied twice yields one row. A rev-gated stale replay yields no change. +- **Upvote lifecycle through the real consumer paths:** + - create; + - a second voter bumps the group; + - a stale-vote replacement by a downvote removes the last voter's group; + - delete-then-create flip; + - a blocked voter's upvote does not bump; + - the group is hidden when all voters are blocked. +- **Bridged totals (decision 8):** + - a poll increase above the item's high-water mark creates or bumps the group; a rise back up to a previous high after a decrease does not; + - an increase on an item older than 7 days bumps, and so does an increase whose bridge `asOf` predates activation and is more than 7 days old but newer than the stored `asOf` (no activation or freshness gate); + - two concurrent applications of the same total bump once (the previous total is read under the item's row lock); + - an unchanged total, a stale `asOf` and a decrease do not bump; + - a drop to 0 with no qualifying native upvote deletes the group; + - totals stored before launch notify no one; + - a native vote removal keeps a group that has a bridged total; + - `upvoteCount` adds the total, and `recentUpvoters` stays native-only; + - the recipient gates and the deleted/removed gate apply; + - with `TRUSTED_BRIDGE_PDS_HOSTS` empty, bridged totals are ignored; + - after a restart that removes one of two trusted hosts, the removed host's stored totals still count and never bump again; + - erasure racing the poller deadlocks nowhere. +- **Transactionality.** A failing notification write rolls back the comment, post, or vote index on each commit branch, including comment resurrection and the vote consumer's early-commit branches. +- **Visibility:** + - pending, rejected, awaiting-reacceptance, missing-admission and CID-mismatched postv2 posts as source, subject, and root are hidden; + - author-deleted and moderator-removed postv2 posts that were public at that moment, and deleted comments, are listed with `status` and no excerpt, and count as unread; + - a post deleted or removed while pending stays hidden, and so does a deleted or removed legacy post; + - list and count agree in every case. +- **Author deletion keeps rows.** `deleteComment` and the post tombstone, including a zero-row soft delete, leave every row. The tombstone records an `authorDelete` marker only for a post that was publicly visible. `ApplyRemoval` and `ApplyAcceptanceDelete` maintain the `communityWithdrawal` marker in both delivery orders of a removal commit, write none for a soft-deleted post that is still accepted, follow the committed state under a concurrent admission write, and roll back with a failed marker write. Erasure deletes both kinds. A resurrection keeps the rows, its mention cap carries over, and a different-parent resurrection repairs the old reply row and the root. +- **Write-time gate.** On each consumer path, a comment, edit, post or upvote whose referenced post is deleted or removed (including a comment's parent post that is not its root), or whose parent or subject comment is deleted, writes no row and bumps no group. A pending root still gets its row. Restoring a removed post does not backfill. +- **Blocks in both directions**, on list and count. +- **Preferences**, on list and count. +- **Unread:** + - NULL `seen_at` (only the newest row is unread); + - backwards `updateSeen` is ignored; + - a future `seenAt` is clamped; + - a delayed reply indexed after `seen_at` is unread. +- **Erasure:** + - recipient and actor rows are deleted; + - `notification_state` is deleted; + - a redrive of an erased actor's comment creates nothing; + - concurrent erasure and fan-out, for both the recipient and actor paths (lock-bite tests per `feedback_concurrency_lock_bite`). +- **Eligibility.** Aggregator, bridge-PDS, unknown, and erased recipients get no rows. +- **Retention:** + - read rows are kept up to exactly 30 days before `seen_at` and deleted after; + - a user who has never marked anything seen uses their newest row as the reference; + - the unread cap applies only above 500 unread; + - empty upvote groups are swept; + - deletion runs in 10,000-row batches and continues until a batch comes back short. +- **Count bound.** A count over 101 visible rows returns 101, and the `EXPLAIN` check passes. +- **Handlers.** Every endpoint is tested at T1 with a sealed session minted in-process (`store.SaveSession` + `client.SealSession`), the same pattern existing viewer-scoped handlers use. + +**T2** (`tests/e2e`, hermetic stack): +- **Available now:** every new NSID returns 401 without a session. +- **Blocked on the auth prerequisite:** the pipeline flow for each reason, where user A replies, mentions, or upvotes through a real PDS write, then user B lists notifications, marks them seen, and the count clears. T2 observes only through serving endpoints and the consumer-health endpoint (§3.4 rule 1), and every notification endpoint is viewer-scoped. Until the session unlock, this is the same gap as block enforcement: the consumer wiring is proven at T1 against real Postgres, and T2 proves only the auth boundary. + +**Frontend:** client and polling-store unit tests (visibility pause, logout stop, count-rise reload), plus browser tests for the route, badge, mark-seen watermark, page-one `seenAt` read state, and settings. + +**Mobile:** provider and service unit tests, widget tests for the list, badge, empty and error states, and navigation. One integration test runs against the local dev stack, using local PLC and PDS and real accounts. + +## Rollout + +1. The backend ships first. The endpoints are additive, and `activated_at` is set by the migration. +2. The frontend and mobile ship independently after that. Older mobile builds keep the placeholder tab. +3. There is no feature flag. Notifications accrue from activation onward. + +## Chunks for `/prd-loop` (one loop per repo) + +**`coves`**: done, built in 28 chunks; see `NOTIFICATIONS_IMPLEMENTATION.md`. + +**`coves-frontend`** +1. API client and types, the polling store, and the nav badge. +2. `/notifications` route with the mark-seen watermark and page-one read state. **Manual, outside `/prd-loop`:** the visual design is decided while building this chunk. +3. Settings toggles. + +**`coves-mobile`** +1. Service and provider, plus the nav badge. +2. Notifications screen, `PostDetailScreen` navigation, and mark-seen. **Manual, outside `/prd-loop`:** the visual design is decided while building this chunk. +3. Settings toggles. + +## Resolved questions + +1. **Community bans do not suppress notifications.** A ban currently gates only post admission (`internal/core/posts/admit.go`). There is no ban check on comments, votes, or reads (`comments.ErrBanned` and `votes.ErrBanned` are defined but unused). A banned user's comments stay visible in threads, so notifying about them is consistent. If bans are later extended to comments, notifications follow automatically through the read-time visibility rules. +2. **Retention:** Bluesky's former Postgres-era policy (30 days after read, a 500-unread cap with 180-day cutoff). See Retention. +3. **Removed posts (revised 2026-10-01, decision 7):** existing notifications stay, listed as "[Removed by moderator]" placeholders when the postv2 post was publicly visible at the moment of removal; otherwise they stay hidden. Removal stops new ones at write time. The 2026-09-28 resolution hid them through the anonymous-viewer rule. +4. **`compensateAuthorDelete` housekeeping (moot, 2026-10-01).** A backlog concern noted that `compensateAuthorDelete` (`internal/core/posts/service.go`) soft-deletes a postv2 without notification housekeeping. Author deletes now keep rows by design (decision 7), so no path needs that housekeeping. diff --git a/internal/api/handlers/notification/list_notifications.go b/internal/api/handlers/notification/list_notifications.go new file mode 100644 index 0000000..8c55352 --- /dev/null +++ b/internal/api/handlers/notification/list_notifications.go @@ -0,0 +1,52 @@ +package notification + +import ( + "errors" + "log/slog" + "net/http" + "strconv" + + "Coves/internal/api/middleware" + "Coves/internal/api/xrpc" + "Coves/internal/core/notifications" +) + +const defaultNotificationListLimit = 50 +const maximumNotificationListLimit = 100 + +// ListHandler serves social.coves.notification.listNotifications. +type ListHandler struct{ service notifications.ListService } + +// NewListHandler builds the listNotifications handler. +func NewListHandler(service notifications.ListService) *ListHandler { + return &ListHandler{service: service} +} + +// HandleListNotifications serves the authenticated account's notifications. +func (h *ListHandler) HandleListNotifications(w http.ResponseWriter, r *http.Request) { + userDID := middleware.GetUserDID(r) + if userDID == "" { + xrpc.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + limit := defaultNotificationListLimit + if values, present := r.URL.Query()["limit"]; present { + var err error + limit, err = strconv.Atoi(values[0]) + if err != nil || limit < 1 || limit > maximumNotificationListLimit { + xrpc.WriteError(w, http.StatusBadRequest, "InvalidRequest", "Limit must be between 1 and 100") + return + } + } + output, err := h.service.ListNotifications(r.Context(), userDID, r.URL.Query().Get("cursor"), limit) + if errors.Is(err, notifications.ErrInvalidCursor) { + xrpc.WriteError(w, http.StatusBadRequest, "InvalidCursor", "Invalid notification cursor") + return + } + if err != nil { + slog.ErrorContext(r.Context(), "failed to list notifications", "error", err) + xrpc.WriteError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + return + } + xrpc.WriteJSON(w, http.StatusOK, output) +} diff --git a/internal/api/handlers/notification/list_notifications_bridged_upvote_test.go b/internal/api/handlers/notification/list_notifications_bridged_upvote_test.go new file mode 100644 index 0000000..8fd77df --- /dev/null +++ b/internal/api/handlers/notification/list_notifications_bridged_upvote_test.go @@ -0,0 +1,153 @@ +//go:build integration + +package notification_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "Coves/internal/api/handlers/notification" + "Coves/internal/api/middleware" + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + + "github.com/stretchr/testify/require" +) + +func newBridgedUpvoteListFixture(t *testing.T) *listHandlerFixture { + t.Helper() + f := newListHandlerFixture(t) + f.repo = postgres.NewNotificationRepository(f.db, postgres.WithBridgedUpvoteTotals()).(notifications.ReadRepository) + return f +} + +func (f *listHandlerFixture) setBridgedUpvotes(table, uri string, upvotes, downvotes int) { + f.t.Helper() + require.Contains(f.t, []string{"posts", "comments"}, table) + _, err := f.db.ExecContext(context.Background(), "UPDATE "+table+" SET bridged_upvote_count = $1, bridged_downvote_count = $2, bridged_stats_as_of = NOW() WHERE uri = $3", upvotes, downvotes, uri) + require.NoError(f.t, err) +} + +func (f *listHandlerFixture) bridgedUnreadCount(recipient string) string { + f.t.Helper() + handler := notification.NewHandler(notifications.NewService(f.repo)) + request := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.notification.getUnreadCount", nil) + request = request.WithContext(middleware.SetTestUserDID(request.Context(), recipient)) + response := httptest.NewRecorder() + handler.HandleGetUnreadCount(response, request) + require.Equal(f.t, http.StatusOK, response.Code, "getUnreadCount response: %s", response.Body.String()) + return response.Body.String() +} + +func (f *listHandlerFixture) setBridgedSeenAt(recipient string, at time.Time) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO notification_state (did, seen_at) VALUES ($1, $2)`, recipient, at.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) +} + +func TestListNotifications_BridgedMixedUpvotesKeepNativeVoters(t *testing.T) { + f := newBridgedUpvoteListFixture(t) + thread := f.seedThread("Root", "Body") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + f.addUpvoteGroup(thread.caller, thread.root, thread.root, at) + f.setBridgedUpvotes("posts", thread.root, 5, 4) + older, newer, blocked := "did:plc:older"+f.id, "did:plc:newer"+f.id, "did:plc:blocked"+f.id + for _, voter := range []struct{ did, handle string }{ + {older, "older" + f.id + ".test"}, {newer, "newer" + f.id + ".test"}, {blocked, "blocked" + f.id + ".test"}, + } { + f.addUser(voter.did, voter.handle, "Voter") + } + f.addUpvoteVote(older, thread.root, "up", at.Add(time.Second)) + f.addUpvoteVote(newer, thread.root, "up", at.Add(2*time.Second)) + f.addUpvoteVote(blocked, thread.root, "up", at.Add(3*time.Second)) + f.addUpvoteVote(thread.caller, thread.root, "up", at.Add(4*time.Second)) + _, err := f.db.ExecContext(context.Background(), `INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) VALUES ($1, $2, $3, 'bafyblock')`, thread.caller, blocked, "at://"+thread.caller+"/social.coves.actor.block/blocked") + require.NoError(t, err) + + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1) + require.JSONEq(t, `7`, string(rows[0]["upvoteCount"])) + require.Equal(t, []string{newer, older}, upvoteVoterDIDs(t, rows[0])) +} + +func TestListNotifications_BridgedOnlyGroupVisibilityAndUnread(t *testing.T) { + f := newBridgedUpvoteListFixture(t) + thread := f.seedThread("Root", "Body") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + f.setBridgedSeenAt(thread.caller, at.Add(-time.Second)) + f.addUpvoteGroup(thread.caller, thread.root, thread.root, at) + f.setBridgedUpvotes("posts", thread.root, 5, 4) + zero := "at://" + thread.caller + "/social.coves.community.postv2/zero" + f.addPost(zero, "bafyzero", "zero", thread.caller, thread.community, "Zero", "Body", at) + f.addUpvoteGroup(thread.caller, zero, zero, at.Add(time.Second)) + f.setBridgedUpvotes("posts", zero, 0, 4) + + t.Run("list includes positive total and omits zero total", func(t *testing.T) { + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1) + require.JSONEq(t, `"upvote"`, string(rows[0]["reason"])) + require.JSONEq(t, `"`+thread.root+`"`, string(placeholderObject(t, rows[0], "subject")["uri"])) + require.JSONEq(t, `5`, string(rows[0]["upvoteCount"])) + require.NotContains(t, rows[0], "recentUpvoters") + }) + t.Run("unread includes positive total but not zero total", func(t *testing.T) { + require.JSONEq(t, `{"count":1}`, f.bridgedUnreadCount(thread.caller)) + }) +} + +func TestListNotifications_BridgedOnlyPlaceholdersAndUnread(t *testing.T) { + f := newBridgedUpvoteListFixture(t) + thread := f.seedThread("Root", "Body") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + f.setBridgedSeenAt(thread.caller, at.Add(-time.Second)) + comment := "at://" + thread.caller + "/social.coves.community.comment/own" + f.addComment(comment, "bafyowncomment", "own", thread.caller, thread.root, "bafyroot", thread.root, "bafyroot", "Private comment", at) + f.addUpvoteGroup(thread.caller, comment, thread.root, at) + f.setBridgedUpvotes("comments", comment, 4, 0) + f.softDeleteComment(comment) + removed := "at://" + thread.caller + "/social.coves.community.postv2/removed" + f.addPost(removed, "bafyremoved", "removed", thread.caller, thread.community, "Private title", "Private body", at) + f.addUpvoteGroup(thread.caller, removed, removed, at.Add(time.Second)) + f.setBridgedUpvotes("posts", removed, 4, 0) + f.withdrawPost(removed, "communityWithdrawal") + deleted := "at://" + thread.caller + "/social.coves.community.postv2/deleted" + f.addPost(deleted, "bafydeleted", "deleted", thread.caller, thread.community, "Private title", "Private body", at) + f.addUpvoteGroup(thread.caller, deleted, deleted, at.Add(2*time.Second)) + f.setBridgedUpvotes("posts", deleted, 4, 0) + f.withdrawPost(deleted, "authorDelete") + + t.Run("list contains deleted and moderator-removed placeholders", func(t *testing.T) { + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 3) + bySubject := make(map[string]map[string]json.RawMessage) + for _, row := range rows { + var uri string + require.NoError(t, json.Unmarshal(placeholderObject(t, row, "subject")["uri"], &uri)) + bySubject[uri] = row + } + for _, tc := range []struct{ uri, status string }{ + {comment, "deleted"}, {removed, "removedByModerator"}, {deleted, "deleted"}, + } { + row, ok := bySubject[tc.uri] + require.True(t, ok, "missing upvote group for %s", tc.uri) + require.JSONEq(t, `4`, string(row["upvoteCount"])) + subject := placeholderObject(t, row, "subject") + require.JSONEq(t, `"`+tc.status+`"`, string(subject["status"])) + require.NotContains(t, subject, "preview") + require.NotContains(t, row, "recentUpvoters") + } + }) + t.Run("unread counts all three placeholders", func(t *testing.T) { + require.JSONEq(t, `{"count":3}`, f.bridgedUnreadCount(thread.caller)) + }) +} diff --git a/internal/api/handlers/notification/list_notifications_labels_test.go b/internal/api/handlers/notification/list_notifications_labels_test.go new file mode 100644 index 0000000..e9703c3 --- /dev/null +++ b/internal/api/handlers/notification/list_notifications_labels_test.go @@ -0,0 +1,123 @@ +//go:build integration + +package notification_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + + "github.com/stretchr/testify/require" +) + +func TestListNotifications_StoredSelfLabelsAtEveryPosition(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root", "Body") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + postReply := "at://" + thread.actor + "/social.coves.community.comment/labeledpostreply" + parent := "at://" + thread.caller + "/social.coves.community.comment/labeledparent" + commentReply := "at://" + thread.actor + "/social.coves.community.comment/unlabeledreply" + commentMention := "at://" + thread.actor + "/social.coves.community.comment/labeledmention" + postMention := "at://" + thread.actor + "/social.coves.community.postv2/labeledmention" + f.addComment(postReply, "bafypr", "labeledpostreply", thread.actor, thread.root, "bafyroot", thread.root, "bafyroot", "Spoiler", at) + f.addComment(parent, "bafyparent", "labeledparent", thread.caller, thread.root, "bafyroot", thread.root, "bafyroot", "Parent", at) + f.addComment(commentReply, "bafycr", "unlabeledreply", thread.actor, thread.root, "bafyroot", parent, "bafyparent", "Unlabeled", at) + f.addComment(commentMention, "bafycmention", "labeledmention", thread.actor, thread.root, "bafyroot", thread.root, "bafyroot", "Mention", at) + f.addPost(postMention, "bafypmention", "labeledmention", thread.actor, thread.community, "Mention post", "Body", at) + for _, seed := range []struct{ table, uri, labels string }{ + {"posts", thread.root, `{"values":[{"val":"nsfw"}]}`}, + {"posts", postMention, `{"values":[{"val":"nsfw"}]}`}, + {"comments", postReply, `{"values":[{"val":"spoiler"}]}`}, + {"comments", parent, `{"values":[{"val":"violence"}]}`}, + {"comments", commentMention, `{"values":[{"val":"spoiler"}]}`}, + } { + // Both consumers store the full selfLabels object in the JSONB column. + query := "UPDATE " + seed.table + " SET content_labels = $1 WHERE uri = $2" + _, err := f.db.ExecContext(context.Background(), query, seed.labels, seed.uri) + require.NoError(t, err) + } + f.addReply(thread.caller, notifications.ReasonPostReply, postReply, "bafypr", thread.actor, thread.root, thread.root, at, at) + f.addReply(thread.caller, notifications.ReasonCommentReply, commentReply, "bafycr", thread.actor, parent, thread.root, at, at.Add(time.Second)) + f.addMention(thread.caller, commentMention, "bafycmention", thread.actor, thread.root, at, at.Add(2*time.Second)) + f.addMention(thread.caller, postMention, "bafypmention", thread.actor, postMention, at, at.Add(3*time.Second)) + f.addUpvoteGroup(thread.caller, thread.root, thread.root, at.Add(4*time.Second)) + f.addUpvoteGroup(thread.caller, parent, thread.root, at.Add(5*time.Second)) + voter := "did:plc:labelvoter" + f.id + f.addUser(voter, "labelvoter"+f.id+".test", "Voter") + f.addUpvoteVote(voter, thread.root, "up", at) + f.addUpvoteVote(voter, parent, "up", at.Add(time.Second)) + + postViews, err := postgres.NewPostRepository(f.db).GetViewsByURIs(context.Background(), []string{thread.root, postMention}, "") + require.NoError(t, err) + for uri, want := range map[string]string{thread.root: `{"values":[{"val":"nsfw"}]}`, postMention: `{"values":[{"val":"nsfw"}]}`} { + require.NotNil(t, postViews[uri], "post fixture %s", uri) + labels, err := json.Marshal(postViews[uri].Record.(map[string]interface{})["labels"]) + require.NoError(t, err) + require.JSONEq(t, want, string(labels), "feed record.labels for %s", uri) + } + commentViews, err := postgres.NewCommentRepository(f.db).GetByURIsBatch(context.Background(), []string{postReply, parent, commentReply, commentMention}) + require.NoError(t, err) + for uri, want := range map[string]string{postReply: `{"values":[{"val":"spoiler"}]}`, parent: `{"values":[{"val":"violence"}]}`, commentMention: `{"values":[{"val":"spoiler"}]}`} { + require.NotNil(t, commentViews[uri]) + require.NotNil(t, commentViews[uri].ContentLabels) + require.JSONEq(t, want, *commentViews[uri].ContentLabels, "comment ContentLabels for %s", uri) + } + require.NotNil(t, commentViews[commentReply]) + require.Nil(t, commentViews[commentReply].ContentLabels) + + response := f.request(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 6) + seen := make(map[string]bool) + for _, row := range rows { + reason := string(row["reason"]) + root := placeholderObject(t, row, "rootPost") + require.JSONEq(t, `{"values":[{"val":"nsfw"}]}`, string(root["labels"]), "rootPost.labels for %s", row) + switch reason { + case `"postReply"`, `"commentReply"`, `"mention"`: + record := placeholderObject(t, row, "record") + uri := string(record["uri"]) + require.False(t, seen[uri], "duplicate notification %s", uri) + seen[uri] = true + switch uri { + case `"` + postReply + `"`: + require.JSONEq(t, `{"values":[{"val":"spoiler"}]}`, string(record["labels"])) + require.JSONEq(t, `{"values":[{"val":"nsfw"}]}`, string(placeholderObject(t, row, "subject")["labels"])) + case `"` + commentReply + `"`: + require.NotContains(t, record, "labels", "unlabeled reply does not inherit from its subject or root") + require.JSONEq(t, `{"values":[{"val":"violence"}]}`, string(placeholderObject(t, row, "subject")["labels"])) + case `"` + commentMention + `"`: + require.JSONEq(t, `{"values":[{"val":"spoiler"}]}`, string(record["labels"])) + require.NotContains(t, row, "subject") + case `"` + postMention + `"`: + require.JSONEq(t, `{"values":[{"val":"nsfw"}]}`, string(record["labels"])) + require.NotContains(t, row, "subject") + default: + t.Fatalf("unexpected record %s", uri) + } + case `"upvote"`: + require.NotContains(t, row, "record") + subject := placeholderObject(t, row, "subject") + uri := string(subject["uri"]) + require.False(t, seen[uri], "duplicate upvote group %s", uri) + seen[uri] = true + switch uri { + case `"` + thread.root + `"`: + require.JSONEq(t, `{"values":[{"val":"nsfw"}]}`, string(subject["labels"])) + case `"` + parent + `"`: + require.JSONEq(t, `{"values":[{"val":"violence"}]}`, string(subject["labels"])) + default: + t.Fatalf("unexpected upvote subject %s", uri) + } + default: + t.Fatalf("unexpected reason %s", reason) + } + } + require.Len(t, seen, 6) +} diff --git a/internal/api/handlers/notification/list_notifications_mention_test.go b/internal/api/handlers/notification/list_notifications_mention_test.go new file mode 100644 index 0000000..014ad60 --- /dev/null +++ b/internal/api/handlers/notification/list_notifications_mention_test.go @@ -0,0 +1,234 @@ +//go:build integration + +package notification_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + + "github.com/stretchr/testify/require" +) + +func (f *listHandlerFixture) addMention(recipient, recordURI, oldCID, actor, rootURI string, createdAt, sortAt time.Time) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO notifications (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) VALUES ($1, 'mention', $2, $3, $4, NULL, $5, $6, $7)`, recipient, recordURI, oldCID, actor, rootURI, createdAt.UTC().Truncate(time.Microsecond), sortAt.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) + f.expected[recipient] = append(f.expected[recipient], recordURI) +} + +func TestListNotifications_CommentMentionFullShape(t *testing.T) { + for _, tc := range []struct{ name, content, want string }{ + {"long multi-codepoint excerpt", strings.Repeat("a", 139) + "👩‍👩‍👧‍👦" + strings.Repeat("b", 160), strings.Repeat("a", 139) + "👩‍👩‍👧‍👦"}, + {"short content", "Hello recipient", "Hello recipient"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root title", "Root body") + at := time.Date(2026, 9, 20, 9, 1, 0, 123456000, time.UTC) + uri := "at://" + thread.actor + "/social.coves.community.comment/mention" + f.addComment(uri, "bafycurrentcomment", "mention", thread.actor, thread.root, "bafystaleroot", thread.root, "bafystaleparent", tc.content, at) + f.addMention(thread.caller, uri, "bafystoredcomment", thread.actor, thread.root, at.Add(time.Minute), at.Add(2*time.Minute)) + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "comment mention must be listed") + row := response.body.Notifications[0] + require.Equal(t, notifications.ReasonMention, row.Reason) + require.Equal(t, ¬ifications.ProfileView{DID: thread.actor, Handle: "listactor" + f.id + ".test", DisplayName: stringPointer("Indexed Author")}, row.Author) + require.Equal(t, ¬ifications.RecordView{URI: uri, CID: "bafycurrentcomment", CreatedAt: "2026-09-20T09:02:00.123456Z", Excerpt: tc.want}, row.Record) + require.NotNil(t, row.RootPost) + require.Equal(t, thread.root, row.RootPost.URI) + require.Equal(t, "bafyroot", row.RootPost.CID) + require.Equal(t, "Root title", row.RootPost.Title) + require.NotNil(t, row.RootPost.Community) + require.Equal(t, thread.community, row.RootPost.Community.DID) + require.Equal(t, "Thread Community", row.RootPost.Community.Name) + require.NotContains(t, rows[0], "subject") + require.NotContains(t, rows[0], "upvoteCount") + }) + } +} + +func TestListNotifications_PostMentionTitleOrBodyExcerpt(t *testing.T) { + for _, tc := range []struct{ name, title, body, want string }{ + {"title truncated on grapheme", strings.Repeat("T", 139) + "🇺🇸" + strings.Repeat("X", 160), "Other body", strings.Repeat("T", 139) + "🇺🇸"}, + {"untitled body truncated on grapheme", "", strings.Repeat("b", 139) + "👩‍👩‍👧‍👦" + strings.Repeat("c", 160), strings.Repeat("b", 139) + "👩‍👩‍👧‍👦"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Existing root", "Existing body") + at := time.Date(2026, 9, 20, 9, 1, 0, 123456000, time.UTC) + uri := "at://" + thread.actor + "/social.coves.community.postv2/mention" + f.addPost(uri, "bafycurrentpost", "mention", thread.actor, thread.community, tc.title, tc.body, at) + f.addMention(thread.caller, uri, "bafystoredpost", thread.actor, uri, at.Add(time.Minute), at.Add(2*time.Minute)) + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "post mention must be listed") + row := response.body.Notifications[0] + require.Equal(t, notifications.ReasonMention, row.Reason) + require.Equal(t, ¬ifications.RecordView{URI: uri, CID: "bafycurrentpost", CreatedAt: "2026-09-20T09:02:00.123456Z", Excerpt: tc.want}, row.Record) + require.NotNil(t, row.RootPost) + require.Equal(t, uri, row.RootPost.URI) + require.Equal(t, "bafycurrentpost", row.RootPost.CID) + require.Equal(t, tc.title, row.RootPost.Title, "root title is complete") + require.NotNil(t, row.RootPost.Community) + require.Equal(t, thread.community, row.RootPost.Community.DID) + require.NotContains(t, rows[0], "subject") + require.NotContains(t, rows[0], "upvoteCount") + if tc.title == "" { + require.NotContains(t, placeholderObject(t, rows[0], "rootPost"), "title") + } + }) + } +} + +func TestListNotifications_MentionPlaceholders(t *testing.T) { + for _, tc := range []struct { + name string + postMention bool + withdrawal, status string + deletedComment bool + }{ + {"post author deleted", true, "authorDelete", "deleted", false}, + {"post moderator removed", true, "communityWithdrawal", "removedByModerator", false}, + {"comment deleted", false, "", "deleted", true}, + {"comment under deleted root", false, "authorDelete", "deleted", false}, + {"comment under removed root", false, "communityWithdrawal", "removedByModerator", false}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root title", "Private root body") + at := time.Date(2026, 9, 20, 9, 1, 0, 123456000, time.UTC) + recordURI, rootURI := "at://"+thread.actor+"/social.coves.community.comment/mention", thread.root + recordCID := "bafycurrentcomment" + if tc.postMention { + recordURI = "at://" + thread.actor + "/social.coves.community.postv2/mention" + rootURI = recordURI + recordCID = "bafycurrentpost" + f.addPost(recordURI, recordCID, "mention", thread.actor, thread.community, "Private post title", "Private post body", at) + } else { + f.addComment(recordURI, recordCID, "mention", thread.actor, rootURI, "bafystaleroot", rootURI, "bafystaleroot", "Comment visible when live", at) + } + f.addMention(thread.caller, recordURI, "bafystoredold", thread.actor, rootURI, at, at) + if tc.deletedComment { + f.softDeleteComment(recordURI) + } + if tc.withdrawal != "" { + f.withdrawPost(rootURI, tc.withdrawal) + } + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "mention placeholder must remain listed") + require.Equal(t, notifications.ReasonMention, response.body.Notifications[0].Reason) + require.NotContains(t, rows[0], "subject") + require.NotContains(t, rows[0], "upvoteCount") + record := placeholderObject(t, rows[0], "record") + root := placeholderObject(t, rows[0], "rootPost") + require.JSONEq(t, `"`+recordURI+`"`, string(record["uri"])) + require.JSONEq(t, `"`+recordCID+`"`, string(record["cid"])) + require.JSONEq(t, `"2026-09-20T09:01:00.123456Z"`, string(record["createdAt"])) + require.JSONEq(t, `"`+rootURI+`"`, string(root["uri"])) + if tc.postMention { + require.JSONEq(t, `"bafycurrentpost"`, string(root["cid"])) + require.JSONEq(t, `"`+tc.status+`"`, string(record["status"])) + require.NotContains(t, record, "excerpt") + require.JSONEq(t, `"`+tc.status+`"`, string(root["status"])) + require.NotContains(t, root, "title") + require.NotContains(t, root, "community") + } else if tc.deletedComment { + require.JSONEq(t, `"deleted"`, string(record["status"])) + require.NotContains(t, record, "excerpt") + require.JSONEq(t, `"bafyroot"`, string(root["cid"])) + require.JSONEq(t, `"Root title"`, string(root["title"])) + require.Contains(t, root, "community") + require.NotContains(t, root, "status") + } else { + require.JSONEq(t, `"Comment visible when live"`, string(record["excerpt"])) + require.NotContains(t, record, "status") + require.JSONEq(t, `"bafyroot"`, string(root["cid"])) + require.JSONEq(t, `"`+tc.status+`"`, string(root["status"])) + require.NotContains(t, root, "title") + require.NotContains(t, root, "community") + } + }) + } +} + +func TestListNotifications_MixedMentionsBatchHydration(t *testing.T) { + for _, perKind := range []int{2, 12} { + t.Run(fmt.Sprintf("%d of each kind", perKind), func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Base root", "Base body") + second := "did:plc:mentionsecond" + f.id + f.addUser(second, "mentionsecond"+f.id+".test", "Second Author") + actors := []string{thread.actor, second} + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + want := map[string]notifications.Reason{} + for index := 0; index < perKind; index++ { + actor := actors[index%2] + rootKey := fmt.Sprintf("mixroot%d", index) + rootURI := "at://" + thread.caller + "/social.coves.community.postv2/" + rootKey + rootCID := "bafy" + rootKey + f.addPost(rootURI, rootCID, rootKey, thread.caller, thread.community, "Root "+rootKey, "Root body", at) + parentKey := fmt.Sprintf("mixparent%d", index) + parentURI := "at://" + thread.caller + "/social.coves.community.comment/" + parentKey + f.addComment(parentURI, "bafy"+parentKey, parentKey, thread.caller, rootURI, rootCID, rootURI, rootCID, "Parent "+parentKey, at) + for _, reason := range []notifications.Reason{notifications.ReasonPostReply, notifications.ReasonCommentReply, notifications.ReasonMention} { + key := fmt.Sprintf("mixed%s%d", reason, index) + uri := "at://" + actor + "/social.coves.community.comment/" + key + f.addComment(uri, "bafy"+key, key, actor, rootURI, rootCID, rootURI, rootCID, "Text "+key, at) + if reason == notifications.ReasonMention { + f.addMention(thread.caller, uri, "bafystored"+key, actor, rootURI, at, at) + } else { + subject := rootURI + if reason == notifications.ReasonCommentReply { + subject = parentURI + } + f.addReply(thread.caller, reason, uri, "bafystored"+key, actor, subject, rootURI, at, at) + } + want[uri] = reason + } + key := fmt.Sprintf("mixedpost%d", index) + uri := "at://" + actor + "/social.coves.community.postv2/" + key + f.addPost(uri, "bafy"+key, key, actor, thread.community, "Title "+key, "Body", at) + f.addMention(thread.caller, uri, "bafystored"+key, actor, uri, at, at) + want[uri] = notifications.ReasonMention + } + repo := &countedNotificationReads{ReadRepository: f.repo} + profiles := &countedProfiles{ProfileLookup: postgres.NewUserRepository(f.db)} + posts := &countedPosts{PostViewLookup: postgres.NewPostRepository(f.db)} + comments := &countedComments{CommentLookup: postgres.NewCommentRepository(f.db)} + response := f.requestWithListDependencies(thread.caller, fmt.Sprintf("limit=%d", len(want)), repo, profiles, posts, comments) + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Equal(t, len(want), len(rows), "all four kinds fill the page") + seen := map[string]bool{} + for _, row := range rows { + var record map[string]json.RawMessage + require.NoError(t, json.Unmarshal(row["record"], &record)) + var uri string + require.NoError(t, json.Unmarshal(record["uri"], &uri)) + require.Equal(t, want[uri], notifications.Reason(strings.Trim(string(row["reason"]), `"`))) + require.NotEmpty(t, record["excerpt"], "record %s has excerpt", uri) + require.NotEmpty(t, placeholderObject(t, row, "rootPost")["title"], "record %s has root title", uri) + require.NotEmpty(t, row["author"]) + seen[uri] = true + } + require.Len(t, seen, len(want)) + require.Equal(t, 1, repo.lists) + require.Equal(t, 1, profiles.calls) + require.Equal(t, 1, posts.calls) + require.Equal(t, 1, comments.calls) + }) + } +} diff --git a/internal/api/handlers/notification/list_notifications_placeholder_test.go b/internal/api/handlers/notification/list_notifications_placeholder_test.go new file mode 100644 index 0000000..69e0a7a --- /dev/null +++ b/internal/api/handlers/notification/list_notifications_placeholder_test.go @@ -0,0 +1,256 @@ +//go:build integration + +package notification_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "Coves/internal/api/handlers/notification" + "Coves/internal/api/middleware" + "Coves/internal/core/comments" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/core/users" + "Coves/internal/db/postgres" + + "github.com/stretchr/testify/require" +) + +// Directly invokes the handler so a missing row fails at the response assertion, +// rather than the pre-check in request, while allowing real repositories to be wrapped. +func (f *listHandlerFixture) requestWithListDependencies(callerDID, query string, repo notifications.ReadRepository, profiles notifications.ProfileLookup, postViews notifications.PostViewLookup, commentLookup notifications.CommentLookup) listHandlerResponse { + f.t.Helper() + handler := notification.NewListHandler(notifications.NewListService(repo, profiles, postViews, commentLookup)) + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.notification.listNotifications?"+query, nil) + req = req.WithContext(middleware.SetTestUserDID(req.Context(), callerDID)) + rec := httptest.NewRecorder() + handler.HandleListNotifications(rec, req) + result := listHandlerResponse{status: rec.Code, raw: rec.Body.Bytes()} + if rec.Code == http.StatusOK && len(result.raw) > 0 { + require.NoError(f.t, json.Unmarshal(result.raw, &result.body), "response: %s", result.raw) + } + return result +} + +func (f *listHandlerFixture) placeholderRequest(callerDID, query string) listHandlerResponse { + f.t.Helper() + return f.requestWithListDependencies(callerDID, query, f.repo, postgres.NewUserRepository(f.db), postgres.NewPostRepository(f.db), postgres.NewCommentRepository(f.db)) +} + +func (f *listHandlerFixture) softDeleteComment(uri string) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `UPDATE comments SET deleted_at = $1 WHERE uri = $2`, time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC), uri) + require.NoError(f.t, err) +} + +func (f *listHandlerFixture) withdrawPost(uri, kind string) { + f.t.Helper() + if kind == "authorDelete" { + _, err := f.db.ExecContext(context.Background(), `UPDATE posts SET deleted_at = $1 WHERE uri = $2`, time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC), uri) + require.NoError(f.t, err) + _, err = f.db.ExecContext(context.Background(), `INSERT INTO notification_public_post_withdrawals (post_uri, kind, community_rev) VALUES ($1, 'authorDelete', NULL)`, uri) + require.NoError(f.t, err) + return + } + _, err := f.db.ExecContext(context.Background(), `UPDATE community_post_admissions SET status = 'removed', decision_code = 'communityRule', decision_at = $1, acceptance_uri = NULL, acceptance_rkey = NULL, accepted_cid = NULL WHERE post_uri = $2`, time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC), uri) + require.NoError(f.t, err) + _, err = f.db.ExecContext(context.Background(), `INSERT INTO notification_public_post_withdrawals (post_uri, kind, community_rev) VALUES ($1, 'communityWithdrawal', '3lqqqqqqqqqq3')`, uri) + require.NoError(f.t, err) +} + +func placeholderObject(t *testing.T, row map[string]json.RawMessage, key string) map[string]json.RawMessage { + t.Helper() + var object map[string]json.RawMessage + require.NoError(t, json.Unmarshal(row[key], &object)) + require.NotNil(t, object, "%s must be present", key) + return object +} + +func TestListNotifications_DeletedRecordPlaceholder(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root title", "Root body") + at := time.Date(2026, 9, 20, 9, 1, 0, 123456000, time.UTC) + replyURI := "at://" + thread.actor + "/social.coves.community.comment/reply" + f.addComment(replyURI, "bafycurrentreply", "reply", thread.actor, thread.root, "bafyroot", thread.root, "bafyroot", "Private deleted reply", at) + f.addReply(thread.caller, notifications.ReasonPostReply, replyURI, "bafystoredoldreply", thread.actor, thread.root, thread.root, at, at) + f.softDeleteComment(replyURI) + + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "deleted reply must remain listed") + require.JSONEq(t, `{"uri":"`+replyURI+`","cid":"bafycurrentreply","createdAt":"2026-09-20T09:01:00.123456Z","status":"deleted"}`, string(rows[0]["record"])) + require.NotContains(t, placeholderObject(t, rows[0], "record"), "excerpt") + require.Equal(t, ¬ifications.ProfileView{DID: thread.actor, Handle: "listactor" + f.id + ".test", DisplayName: stringPointer("Indexed Author")}, response.body.Notifications[0].Author) + require.JSONEq(t, `"Root title"`, string(placeholderObject(t, rows[0], "rootPost")["title"])) + require.Contains(t, placeholderObject(t, rows[0], "rootPost"), "community") + require.JSONEq(t, `"Root title"`, string(placeholderObject(t, rows[0], "subject")["preview"])) + for _, key := range []string{"rootPost", "subject"} { + require.NotContains(t, placeholderObject(t, rows[0], key), "status") + } +} + +func TestListNotifications_WithdrawnPostAndCommentPlaceholders(t *testing.T) { + t.Run("author deleted subject and root", func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Private title", "Private content") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + replyURI := "at://" + thread.actor + "/social.coves.community.comment/reply" + f.addComment(replyURI, "bafyreply", "reply", thread.actor, thread.root, "bafyroot", thread.root, "bafyroot", "Visible reply", at) + f.addReply(thread.caller, notifications.ReasonPostReply, replyURI, "bafyoldreply", thread.actor, thread.root, thread.root, at, at) + f.withdrawPost(thread.root, "authorDelete") + + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "reply to author-deleted post must remain listed") + want := `{"uri":"` + thread.root + `","cid":"bafyroot","status":"deleted"}` + require.JSONEq(t, want, string(rows[0]["rootPost"])) + require.JSONEq(t, want, string(rows[0]["subject"])) + for _, field := range []string{"title", "community"} { + require.NotContains(t, placeholderObject(t, rows[0], "rootPost"), field) + } + require.NotContains(t, placeholderObject(t, rows[0], "subject"), "preview") + require.JSONEq(t, `"Visible reply"`, string(placeholderObject(t, rows[0], "record")["excerpt"])) + require.NotContains(t, placeholderObject(t, rows[0], "record"), "status") + }) + t.Run("deleted parent with moderator removed root", func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Removed title", "Removed content") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + parentURI := "at://" + thread.caller + "/social.coves.community.comment/parent" + replyURI := "at://" + thread.actor + "/social.coves.community.comment/reply" + f.addComment(parentURI, "bafyparent", "parent", thread.caller, thread.root, "bafyroot", thread.root, "bafyroot", "Private parent", at) + f.addComment(replyURI, "bafyreply", "reply", thread.actor, thread.root, "bafyroot", parentURI, "bafyparent", "Visible reply", at.Add(time.Minute)) + f.addReply(thread.caller, notifications.ReasonCommentReply, replyURI, "bafyoldreply", thread.actor, parentURI, thread.root, at.Add(time.Minute), at.Add(time.Minute)) + f.softDeleteComment(parentURI) + f.withdrawPost(thread.root, "communityWithdrawal") + + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "reply to deleted parent on removed root must remain listed") + require.JSONEq(t, `{"uri":"`+parentURI+`","cid":"bafyparent","status":"deleted"}`, string(rows[0]["subject"])) + require.JSONEq(t, `{"uri":"`+thread.root+`","cid":"bafyroot","status":"removedByModerator"}`, string(rows[0]["rootPost"])) + require.NotContains(t, placeholderObject(t, rows[0], "subject"), "preview") + for _, field := range []string{"title", "community"} { + require.NotContains(t, placeholderObject(t, rows[0], "rootPost"), field) + } + require.JSONEq(t, `"Visible reply"`, string(placeholderObject(t, rows[0], "record")["excerpt"])) + require.NotContains(t, placeholderObject(t, rows[0], "record"), "status") + }) +} + +type countedNotificationReads struct { + notifications.ReadRepository + lists int +} + +func (r *countedNotificationReads) List(ctx context.Context, did, cursor string, limit int) (notifications.ListPage, error) { + r.lists++ + return r.ReadRepository.List(ctx, did, cursor, limit) +} + +type countedProfiles struct { + notifications.ProfileLookup + calls int +} + +func (p *countedProfiles) GetByDIDs(ctx context.Context, dids []string) (map[string]*users.User, error) { + p.calls++ + return p.ProfileLookup.GetByDIDs(ctx, dids) +} + +type countedPosts struct { + notifications.PostViewLookup + calls int +} + +func (p *countedPosts) GetViewsByURIs(ctx context.Context, uris []string, viewer string) (map[string]*posts.PostView, error) { + p.calls++ + return p.PostViewLookup.GetViewsByURIs(ctx, uris, viewer) +} + +type countedComments struct { + notifications.CommentLookup + calls int +} + +func (c *countedComments) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) { + c.calls++ + return c.CommentLookup.GetByURIsBatch(ctx, uris) +} + +func TestListNotifications_PlaceholdersBatchWithinOneLookupPerKind(t *testing.T) { + for _, count := range []int{50, 2} { + t.Run(fmt.Sprintf("page of %d", count), func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Live root", "Live body") + base := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + deletedPostURI := "at://" + thread.caller + "/social.coves.community.postv2/deleted" + removedPostURI := "at://" + thread.caller + "/social.coves.community.postv2/removed" + f.addPost(deletedPostURI, "bafydeletedpost", "deleted", thread.caller, thread.community, "Deleted title", "Private body", base) + f.addPost(removedPostURI, "bafyremovedpost", "removed", thread.caller, thread.community, "Removed title", "Private body", base) + parentURI := "at://" + thread.caller + "/social.coves.community.comment/parent" + removedParentURI := "at://" + thread.caller + "/social.coves.community.comment/removedparent" + f.addComment(parentURI, "bafyparent", "parent", thread.caller, thread.root, "bafyroot", thread.root, "bafyroot", "Deleted parent", base) + f.addComment(removedParentURI, "bafyremovedparent", "removedparent", thread.caller, removedPostURI, "bafyremovedpost", removedPostURI, "bafyremovedpost", "Parent", base) + // Distinct actors and distinct live posts make a per-DID or per-URI lookup loop issue more than one call. + secondActor := "did:plc:listactorb" + f.id + f.addUser(secondActor, "listactorb"+f.id+".test", "Second Author") + actors := []string{thread.actor, secondActor} + for i := 0; i < count; i++ { + rkey := fmt.Sprintf("batchreply%d", i) + actor := actors[i%len(actors)] + uri := "at://" + actor + "/social.coves.community.comment/" + rkey + rootURI, rootCID, subjectURI, subjectCID, reason := thread.root, "bafyroot", thread.root, "bafyroot", notifications.ReasonPostReply + switch i % 5 { + case 0, 1: + postRkey := fmt.Sprintf("batchroot%d", i) + rootURI, rootCID = "at://"+thread.caller+"/social.coves.community.postv2/"+postRkey, "bafy"+postRkey + subjectURI, subjectCID = rootURI, rootCID + f.addPost(rootURI, rootCID, postRkey, thread.caller, thread.community, "Live root", "Live body", base) + case 2: + rootURI, rootCID, subjectURI, subjectCID = deletedPostURI, "bafydeletedpost", deletedPostURI, "bafydeletedpost" + case 3: + subjectURI, subjectCID, reason = parentURI, "bafyparent", notifications.ReasonCommentReply + case 4: + rootURI, rootCID, subjectURI, subjectCID, reason = removedPostURI, "bafyremovedpost", removedParentURI, "bafyremovedparent", notifications.ReasonCommentReply + } + at := base.Add(time.Duration(i+1) * time.Second) + f.addComment(uri, "bafy"+rkey, rkey, actor, rootURI, rootCID, subjectURI, subjectCID, "Reply", at) + f.addReply(thread.caller, reason, uri, "bafystoredold"+rkey, actor, subjectURI, rootURI, at, at) + if i%5 == 1 { + f.softDeleteComment(uri) + } + } + f.softDeleteComment(parentURI) + f.withdrawPost(deletedPostURI, "authorDelete") + f.withdrawPost(removedPostURI, "communityWithdrawal") + repo := &countedNotificationReads{ReadRepository: f.repo} + profiles := &countedProfiles{ProfileLookup: postgres.NewUserRepository(f.db)} + postViews := &countedPosts{PostViewLookup: postgres.NewPostRepository(f.db)} + commentLookup := &countedComments{CommentLookup: postgres.NewCommentRepository(f.db)} + response := f.requestWithListDependencies(thread.caller, fmt.Sprintf("limit=%d", count), repo, profiles, postViews, commentLookup) + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Equal(t, count, len(rows), "live and placeholder rows all fill the page") + var returned []string + for _, row := range response.body.Notifications { + require.NotNil(t, row.Record) + returned = append(returned, row.Record.URI) + } + require.ElementsMatch(t, f.expected[thread.caller], returned) + require.Equal(t, 1, repo.lists) + require.Equal(t, 1, profiles.calls, "authors load in one batched lookup") + require.Equal(t, 1, postViews.calls, "live posts load in one batched lookup") + require.LessOrEqual(t, commentLookup.calls, 1) + }) + } +} diff --git a/internal/api/handlers/notification/list_notifications_test.go b/internal/api/handlers/notification/list_notifications_test.go new file mode 100644 index 0000000..0f6ea60 --- /dev/null +++ b/internal/api/handlers/notification/list_notifications_test.go @@ -0,0 +1,476 @@ +//go:build integration + +package notification_test + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "Coves/internal/api/handlers/notification" + "Coves/internal/api/middleware" + "Coves/internal/core/blobs" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type listHandlerFixture struct { + t *testing.T + id string + db *sql.DB + repo notifications.ReadRepository + expected map[string][]string +} + +func newListHandlerFixture(t *testing.T) *listHandlerFixture { + t.Helper() + db := testkit.DB(t) + return &listHandlerFixture{t: t, id: testkit.UniqueID(t), db: db, repo: postgres.NewNotificationRepository(db).(notifications.ReadRepository), expected: make(map[string][]string)} +} + +func (f *listHandlerFixture) addUser(did, handle, displayName string) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO users (did, handle, pds_url, display_name) VALUES ($1, $2, $3, $4)`, did, handle, "https://pds.test", displayName) + require.NoError(f.t, err) +} + +func (f *listHandlerFixture) addCommunity(did, ownerDID, handle, name string, at time.Time) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) VALUES ($1, $2, $3, $4, $4, $4, $5)`, did, handle, name, ownerDID, at.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) +} + +func (f *listHandlerFixture) addPost(uri, cid, rkey, authorDID, communityDID, title, content string, at time.Time) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, content, created_at, score, upvote_count, downvote_count) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 1, 0)`, uri, cid, rkey, authorDID, communityDID, title, content, at.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) + _, err = f.db.ExecContext(context.Background(), `INSERT INTO community_post_admissions (community_did, post_uri, status, acceptance_uri, acceptance_rkey, accepted_cid, evaluated_cid, last_community_rev, last_community_op_rank, created_at, updated_at) VALUES ($1, $2, 'accepted', $3, $4, $5, $5, '3lqqqqqqqqqq2', $6, $7, $7)`, communityDID, uri, "at://"+communityDID+"/social.coves.community.acceptance/"+rkey, rkey, cid, int16(posts.CommunityOpPut), at.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) +} + +func (f *listHandlerFixture) addComment(uri, cid, rkey, authorDID, rootURI, rootCID, parentURI, parentCID, content string, at time.Time) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, uri, cid, rkey, authorDID, rootURI, rootCID, parentURI, parentCID, content, at.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) +} + +func (f *listHandlerFixture) addReply(recipientDID string, reason notifications.Reason, recordURI, recordCID, actorDID, subjectURI, rootURI string, recordCreatedAt, sortAt time.Time) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO notifications (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, recipientDID, reason, recordURI, recordCID, actorDID, subjectURI, rootURI, recordCreatedAt.UTC().Truncate(time.Microsecond), sortAt.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) + f.expected[recipientDID] = append(f.expected[recipientDID], recordURI) +} + +type listThread struct { + caller, actor, community, root string +} + +func (f *listHandlerFixture) seedThread(title, content string) listThread { + f.t.Helper() + thread := listThread{ + caller: "did:plc:listcaller" + f.id, + actor: "did:plc:listactor" + f.id, + community: "did:plc:listcommunity" + f.id, + } + thread.root = "at://" + thread.caller + "/social.coves.community.postv2/root" + f.addUser(thread.caller, "listcaller"+f.id+".test", "Caller") + f.addUser(thread.actor, "listactor"+f.id+".test", "Indexed Author") + base := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + f.addCommunity(thread.community, thread.caller, "listcommunity"+f.id+".coves.social", "Thread Community", base) + f.addPost(thread.root, "bafyroot", "root", thread.caller, thread.community, title, content, base) + return thread +} + +func (f *listHandlerFixture) seedListedReplies(count int) (listThread, []string) { + f.t.Helper() + thread := f.seedThread("Thread title", "Thread body") + base := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + var uris []string + for i := 0; i < count; i++ { + rkey := fmt.Sprintf("reply%d", i) + uri := "at://" + thread.actor + "/social.coves.community.comment/" + rkey + at := base.Add(time.Duration(i+1) * time.Second) + f.addComment(uri, "bafy"+rkey, rkey, thread.actor, thread.root, "bafyroot", thread.root, "bafyroot", "Reply", at) + f.addReply(thread.caller, notifications.ReasonPostReply, uri, "bafy"+rkey, thread.actor, thread.root, thread.root, at, at) + uris = append(uris, uri) + } + return thread, uris +} + +func listRawRows(t *testing.T, response listHandlerResponse) []map[string]json.RawMessage { + t.Helper() + var body struct { + Notifications []map[string]json.RawMessage `json:"notifications"` + } + require.NoError(t, json.Unmarshal(response.raw, &body)) + return body.Notifications +} + +func listXRPCError(t *testing.T, response listHandlerResponse) (string, string) { + t.Helper() + var body struct { + Error string `json:"error"` + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(response.raw, &body)) + return body.Error, body.Message +} + +type listHandlerResponse struct { + status int + body notifications.ListNotificationsOutput + raw []byte +} + +func (f *listHandlerFixture) request(callerDID, query string) listHandlerResponse { + f.t.Helper() + // A missing row in the real chunk-16 reader is a fixture defect, not a + // listNotifications handler failure. Check every seeded recipient. + for did, expected := range f.expected { + found := make(map[string]bool, len(expected)) + cursor := "" + for { + page, err := f.repo.List(context.Background(), did, cursor, 100) + require.NoError(f.t, err) + for _, row := range page.Notifications { + found[row.RecordURI] = true + } + if page.Cursor == "" { + break + } + cursor = page.Cursor + } + for _, uri := range expected { + if !found[uri] { + f.t.Fatalf("list fixture: ReadRepository.List hid seeded record %s for %s", uri, did) + } + } + } + handler := notification.NewListHandler(notifications.NewListService(f.repo, postgres.NewUserRepository(f.db), postgres.NewPostRepository(f.db), postgres.NewCommentRepository(f.db))) + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.notification.listNotifications?"+query, nil) + req = req.WithContext(middleware.SetTestUserDID(req.Context(), callerDID)) + rec := httptest.NewRecorder() + handler.HandleListNotifications(rec, req) + result := listHandlerResponse{status: rec.Code, raw: rec.Body.Bytes()} + if rec.Code == http.StatusOK && len(result.raw) > 0 { + require.NoError(f.t, json.Unmarshal(result.raw, &result.body), "response: %s", result.raw) + } + return result +} + +func TestListNotifications_PostReplyFullShape(t *testing.T) { + f := newListHandlerFixture(t) + caller, other, replier := "did:plc:listcaller"+f.id, "did:plc:listother"+f.id, "did:plc:listreplier"+f.id + f.addUser(caller, "listcaller"+f.id+".test", "Caller") + f.addUser(other, "listother"+f.id+".test", "Other") + f.addUser(replier, "listreplier"+f.id+".test", "Reply Author") + base := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + community := "did:plc:listcommunity" + f.id + f.addCommunity(community, caller, "listcommunity"+f.id+".coves.social", "List Community", base) + postURI := "at://" + caller + "/social.coves.community.postv2/root" + otherPostURI := "at://" + other + "/social.coves.community.postv2/other" + f.addPost(postURI, "bafycurrentpost", "root", caller, community, "Current post title", "Current post content", base) + f.addPost(otherPostURI, "bafyotherpost", "other", other, community, "Other title", "Other content", base) + replyURI := "at://" + replier + "/social.coves.community.comment/reply" + otherReplyURI := "at://" + replier + "/social.coves.community.comment/otherreply" + f.addComment(replyURI, "bafycurrentreply", "reply", replier, postURI, "bafystaleroot", postURI, "bafystaleparent", "A clear answer.", base.Add(time.Minute)) + f.addComment(otherReplyURI, "bafyotherreply", "otherreply", replier, otherPostURI, "bafyotherpost", otherPostURI, "bafyotherpost", "Not for caller", base.Add(time.Minute)) + f.addReply(caller, notifications.ReasonPostReply, replyURI, "bafystalereply", replier, postURI, postURI, base.Add(2*time.Minute), base.Add(3*time.Minute)) + f.addReply(other, notifications.ReasonPostReply, otherReplyURI, "bafyotherreply", replier, otherPostURI, otherPostURI, base.Add(2*time.Minute), base.Add(4*time.Minute)) + + response := f.request(caller, "did="+other+"&recipient="+other) + require.Equal(t, http.StatusOK, response.status, "listNotifications response: %s", response.raw) + require.Contains(t, string(response.raw), `"notifications"`, "listNotifications must return a JSON response") + require.Len(t, response.body.Notifications, 1, "only the authenticated caller's reply is returned") + row := response.body.Notifications[0] + require.Equal(t, notifications.ReasonPostReply, row.Reason) + require.Equal(t, "2026-09-20T09:03:00Z", row.SortAt) + require.Equal(t, ¬ifications.ProfileView{DID: replier, Handle: "listreplier" + f.id + ".test", DisplayName: stringPointer("Reply Author")}, row.Author) + require.Equal(t, ¬ifications.RecordView{URI: replyURI, CID: "bafycurrentreply", Excerpt: "A clear answer.", CreatedAt: "2026-09-20T09:02:00Z"}, row.Record) + require.Equal(t, ¬ifications.SubjectView{URI: postURI, CID: "bafycurrentpost", Preview: "Current post title"}, row.Subject) + require.NotNil(t, row.RootPost) + require.Equal(t, postURI, row.RootPost.URI) + require.Equal(t, "bafycurrentpost", row.RootPost.CID) + require.Equal(t, "Current post title", row.RootPost.Title) + require.NotNil(t, row.RootPost.Community) + require.Equal(t, community, row.RootPost.Community.DID) + require.Equal(t, "List Community", row.RootPost.Community.Name) + var raw struct { + Notifications []map[string]json.RawMessage `json:"notifications"` + } + require.NoError(t, json.Unmarshal(response.raw, &raw)) + for _, key := range []string{"upvoteCount", "recentUpvoters", "status"} { + require.NotContains(t, raw.Notifications[0], key) + } +} + +func stringPointer(value string) *string { return &value } + +func TestListNotifications_CommentReplySubjectAndRoot(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("A different root", "Root body") + base := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + parentURI := "at://" + thread.caller + "/social.coves.community.comment/parent" + replyURI := "at://" + thread.actor + "/social.coves.community.comment/child" + f.addComment(parentURI, "bafyparent", "parent", thread.caller, thread.root, "bafyroot", thread.root, "bafyroot", "A parent's own words", base.Add(time.Minute)) + f.addComment(replyURI, "bafychild", "child", thread.actor, thread.root, "bafyroot", parentURI, "bafyparent", "Child reply", base.Add(2*time.Minute)) + f.addReply(thread.caller, notifications.ReasonCommentReply, replyURI, "bafychild", thread.actor, parentURI, thread.root, base.Add(2*time.Minute), base.Add(3*time.Minute)) + + response := f.request(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + require.Len(t, response.body.Notifications, 1) + row := response.body.Notifications[0] + require.Equal(t, notifications.ReasonCommentReply, row.Reason) + require.Equal(t, ¬ifications.SubjectView{URI: parentURI, CID: "bafyparent", Preview: "A parent's own words"}, row.Subject) + require.NotNil(t, row.RootPost) + require.Equal(t, thread.root, row.RootPost.URI) + require.Equal(t, "bafyroot", row.RootPost.CID) + require.Equal(t, "A different root", row.RootPost.Title) + require.NotNil(t, row.RootPost.Community) + require.Equal(t, thread.community, row.RootPost.Community.DID) + require.Equal(t, "Thread Community", row.RootPost.Community.Name) +} + +func TestListNotifications_PostPreviewTitleOrBodyExcerpt(t *testing.T) { + for _, tc := range []struct { + name, title, content, want string + }{ + {"long title is complete", strings.Repeat("T", 149) + "🇺🇸", "Other body", strings.Repeat("T", 149) + "🇺🇸"}, + {"untitled body is truncated", "", strings.Repeat("b", 139) + "👩‍👩‍👧‍👦" + strings.Repeat("c", 160), strings.Repeat("b", 139) + "👩‍👩‍👧‍👦"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread(tc.title, tc.content) + replyURI := "at://" + thread.actor + "/social.coves.community.comment/reply" + at := time.Date(2026, time.September, 20, 9, 1, 0, 0, time.UTC) + f.addComment(replyURI, "bafyreply", "reply", thread.actor, thread.root, "bafyroot", thread.root, "bafyroot", "Reply", at) + f.addReply(thread.caller, notifications.ReasonPostReply, replyURI, "bafyreply", thread.actor, thread.root, thread.root, at, at) + + response := f.request(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + require.Len(t, response.body.Notifications, 1) + require.Equal(t, ¬ifications.SubjectView{URI: thread.root, CID: "bafyroot", Preview: tc.want}, response.body.Notifications[0].Subject) + }) + } +} + +func TestListNotifications_GraphemeTruncation(t *testing.T) { + for _, tc := range []struct { + name, parent, reply, wantParent, wantReply string + }{ + { + "300 clusters cut on multi-codepoint cluster", + strings.Repeat("a", 139) + "👩‍👩‍👧‍👦" + strings.Repeat("b", 160), + strings.Repeat("c", 139) + "🇺🇸" + strings.Repeat("d", 160), + strings.Repeat("a", 139) + "👩‍👩‍👧‍👦", + strings.Repeat("c", 139) + "🇺🇸", + }, + { + "exactly 140 clusters unchanged", + strings.Repeat("e", 139) + "👩‍👩‍👧‍👦", + strings.Repeat("f", 139) + "🇺🇸", + strings.Repeat("e", 139) + "👩‍👩‍👧‍👦", + strings.Repeat("f", 139) + "🇺🇸", + }, + } { + t.Run(tc.name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root", "Root body") + parentURI := "at://" + thread.caller + "/social.coves.community.comment/parent" + replyURI := "at://" + thread.actor + "/social.coves.community.comment/reply" + at := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + f.addComment(parentURI, "bafyparent", "parent", thread.caller, thread.root, "bafyroot", thread.root, "bafyroot", tc.parent, at) + f.addComment(replyURI, "bafyreply", "reply", thread.actor, thread.root, "bafyroot", parentURI, "bafyparent", tc.reply, at.Add(time.Minute)) + f.addReply(thread.caller, notifications.ReasonCommentReply, replyURI, "bafyreply", thread.actor, parentURI, thread.root, at.Add(time.Minute), at.Add(time.Minute)) + + response := f.request(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + require.Len(t, response.body.Notifications, 1) + require.NotNil(t, response.body.Notifications[0].Subject) + require.NotNil(t, response.body.Notifications[0].Record) + require.Equal(t, tc.wantParent, response.body.Notifications[0].Subject.Preview) + require.Equal(t, tc.wantReply, response.body.Notifications[0].Record.Excerpt) + }) + } +} + +func TestListNotifications_UnindexedAuthorIsDIDOnly(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root", "Body") + unindexed := "did:plc:listunindexed" + f.id + base := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + for index, actor := range []string{thread.actor, unindexed} { + rkey := fmt.Sprintf("reply%d", index) + uri := "at://" + actor + "/social.coves.community.comment/" + rkey + at := base.Add(time.Duration(index+1) * time.Minute) + f.addComment(uri, "bafy"+rkey, rkey, actor, thread.root, "bafyroot", thread.root, "bafyroot", "Reply", at) + f.addReply(thread.caller, notifications.ReasonPostReply, uri, "bafy"+rkey, actor, thread.root, thread.root, at, at) + } + response := f.request(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + require.Len(t, response.body.Notifications, 2) + rows := listRawRows(t, response) + require.Len(t, rows, 2) + var unindexedAuthor, indexedAuthor map[string]json.RawMessage + require.NoError(t, json.Unmarshal(rows[0]["author"], &unindexedAuthor)) + require.Equal(t, map[string]json.RawMessage{"did": json.RawMessage(`"` + unindexed + `"`)}, unindexedAuthor) + require.NoError(t, json.Unmarshal(rows[1]["author"], &indexedAuthor)) + require.JSONEq(t, `"`+thread.actor+`"`, string(indexedAuthor["did"])) + require.JSONEq(t, `"listactor`+f.id+`.test"`, string(indexedAuthor["handle"])) + require.JSONEq(t, `"Indexed Author"`, string(indexedAuthor["displayName"])) +} + +func TestListNotifications_IndexedAuthorAvatar(t *testing.T) { + blobs.ResetImageURLConfigForTesting() + f := newListHandlerFixture(t) + thread := f.seedThread("Root", "Body") + avatarDID := "did:plc:listavatarauthor" + f.addUser(avatarDID, "listavatar"+f.id+".test", "Avatar Author") + _, err := f.db.ExecContext(context.Background(), `UPDATE users SET pds_url = $1, avatar_cid = $2 WHERE did = $3`, "https://avatars.test", "bafyavatar", avatarDID) + require.NoError(t, err) + + base := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + for index, actor := range []string{avatarDID, thread.actor} { + rkey := fmt.Sprintf("avatarreply%d", index) + uri := "at://" + actor + "/social.coves.community.comment/" + rkey + at := base.Add(time.Duration(index+1) * time.Minute) + f.addComment(uri, "bafy"+rkey, rkey, actor, thread.root, "bafyroot", thread.root, "bafyroot", "Reply", at) + f.addReply(thread.caller, notifications.ReasonPostReply, uri, "bafy"+rkey, actor, thread.root, thread.root, at, at) + } + + response := f.request(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 2) + var withoutAvatar, withAvatar map[string]json.RawMessage + require.NoError(t, json.Unmarshal(rows[0]["author"], &withoutAvatar)) + require.JSONEq(t, `"`+thread.actor+`"`, string(withoutAvatar["did"])) + require.NotContains(t, withoutAvatar, "avatar") + require.NoError(t, json.Unmarshal(rows[1]["author"], &withAvatar)) + require.JSONEq(t, `"did:plc:listavatarauthor"`, string(withAvatar["did"])) + require.JSONEq(t, `"https://avatars.test/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Alistavatarauthor&cid=bafyavatar"`, string(withAvatar["avatar"])) +} + +func TestListNotifications_LimitValidation(t *testing.T) { + f := newListHandlerFixture(t) + thread, _ := f.seedListedReplies(101) + for _, tc := range []struct { + name, query string + count int + valid bool + }{ + {"one", "limit=1", 1, true}, + {"hundred", "limit=100", 100, true}, + {"default", "", 50, true}, + {"zero", "limit=0", 0, false}, + {"over maximum", "limit=101", 0, false}, + {"not integer", "limit=abc", 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + response := f.request(thread.caller, tc.query) + if !tc.valid { + require.Equal(t, http.StatusBadRequest, response.status, "response: %s", response.raw) + name, _ := listXRPCError(t, response) + require.Equal(t, "InvalidRequest", name) + return + } + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + require.Len(t, response.body.Notifications, tc.count, "response: %s", response.raw) + require.NotEmpty(t, response.body.Cursor) + }) + } +} + +func TestListNotifications_CursorPaging(t *testing.T) { + f := newListHandlerFixture(t) + thread, uris := f.seedListedReplies(3) + malformed := []string{"not-base64!", base64.RawURLEncoding.EncodeToString([]byte("notatime|xyz"))} + var messages []string + for _, tc := range []struct{ name, cursor string }{ + {"non-base64", malformed[0]}, + {"decoded garbage", malformed[1]}, + } { + t.Run(tc.name, func(t *testing.T) { + response := f.request(thread.caller, "cursor="+url.QueryEscape(tc.cursor)) + require.Equal(t, http.StatusBadRequest, response.status, "response: %s", response.raw) + name, message := listXRPCError(t, response) + require.Equal(t, "InvalidCursor", name) + require.NotEmpty(t, message) + for _, cursor := range malformed { + require.NotContains(t, message, cursor) + } + require.NotContains(t, message, "notatime") + require.NotContains(t, message, "xyz") + messages = append(messages, message) + }) + } + if len(messages) == 2 { + require.Equal(t, messages[0], messages[1], "malformed cursor responses must use the same fixed message") + } + t.Run("two pages without duplicates", func(t *testing.T) { + first := f.request(thread.caller, "limit=2") + require.Equal(t, http.StatusOK, first.status, "response: %s", first.raw) + require.Len(t, first.body.Notifications, 2) + require.NotEmpty(t, first.body.Cursor) + require.NotNil(t, first.body.Notifications[0].Record) + require.NotNil(t, first.body.Notifications[1].Record) + require.Equal(t, uris[2], first.body.Notifications[0].Record.URI) + require.Equal(t, uris[1], first.body.Notifications[1].Record.URI) + + second := f.request(thread.caller, "limit=2&cursor="+url.QueryEscape(first.body.Cursor)) + require.Equal(t, http.StatusOK, second.status, "response: %s", second.raw) + require.Len(t, second.body.Notifications, 1) + require.Empty(t, second.body.Cursor) + require.NotNil(t, second.body.Notifications[0].Record) + require.Equal(t, uris[0], second.body.Notifications[0].Record.URI) + require.NotContains(t, []string{first.body.Notifications[0].Record.URI, first.body.Notifications[1].Record.URI}, second.body.Notifications[0].Record.URI) + }) +} + +func TestListNotifications_SeenAtAndIsRead(t *testing.T) { + t.Run("stored seen time and fractional sort time", func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root", "Body") + base := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + _, err := f.db.ExecContext(context.Background(), `INSERT INTO notification_state (did, seen_at) VALUES ($1, $2)`, thread.caller, base.Add(250001*time.Microsecond)) + require.NoError(t, err) + for index, sortAt := range []time.Time{base, base.Add(500 * time.Millisecond)} { + rkey := fmt.Sprintf("reply%d", index) + uri := "at://" + thread.actor + "/social.coves.community.comment/" + rkey + f.addComment(uri, "bafy"+rkey, rkey, thread.actor, thread.root, "bafyroot", thread.root, "bafyroot", "Reply", base) + f.addReply(thread.caller, notifications.ReasonPostReply, uri, "bafy"+rkey, thread.actor, thread.root, thread.root, base, sortAt) + } + response := f.request(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + require.Equal(t, "2026-09-20T09:00:00.250001Z", response.body.SeenAt) + require.Len(t, response.body.Notifications, 2) + require.Equal(t, "2026-09-20T09:00:00.5Z", response.body.Notifications[0].SortAt) + require.False(t, response.body.Notifications[0].IsRead) + require.Equal(t, "2026-09-20T09:00:00Z", response.body.Notifications[1].SortAt) + require.True(t, response.body.Notifications[1].IsRead) + }) + t.Run("no state and empty page", func(t *testing.T) { + f := newListHandlerFixture(t) + caller := "did:plc:listempty" + f.id + f.addUser(caller, "listempty"+f.id+".test", "Empty Caller") + response := f.request(caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + require.Contains(t, string(response.raw), `"notifications":[]`, "empty page must serialize notifications as []") + var body map[string]json.RawMessage + require.NoError(t, json.Unmarshal(response.raw, &body)) + require.Equal(t, "[]", string(body["notifications"])) + require.NotContains(t, body, "seenAt") + }) +} diff --git a/internal/api/handlers/notification/list_notifications_thumbnail_test.go b/internal/api/handlers/notification/list_notifications_thumbnail_test.go new file mode 100644 index 0000000..3f0c83a --- /dev/null +++ b/internal/api/handlers/notification/list_notifications_thumbnail_test.go @@ -0,0 +1,155 @@ +//go:build integration + +package notification_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "Coves/internal/core/blobs" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + + "github.com/stretchr/testify/require" +) + +func TestListNotifications_StoredPostThumbnailsMatchFeedProjection(t *testing.T) { + blobs.ResetImageURLConfigForTesting() + t.Cleanup(blobs.ResetImageURLConfigForTesting) + blobs.SetImageURLConfig(blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test"}) + f := newListHandlerFixture(t) + thread := f.seedThread("Base", "Body") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + const firstCID = "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" + const legacyCID = "bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" + const imageCID = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" + postsToCheck := []struct { + key, uri, embed, literal string + }{ + {"link", "at://" + thread.caller + "/social.coves.community.postv2/thumb-link", `{"$type":"social.coves.embed.external","external":{"uri":"https://article.example.test","title":"Article","description":"Summary","thumb":{"$type":"blob","ref":{"$link":"` + firstCID + `"},"mimeType":"image/jpeg","size":123}}}`, "https://img.example.test/img/embed_thumbnail/plain/" + thread.caller + "/" + firstCID}, + {"legacy", "at://" + thread.community + "/social.coves.community.post/thumb-legacy", `{"$type":"social.coves.embed.external","external":{"uri":"https://article.example.test","title":"Article","description":"Summary","thumb":{"$type":"blob","ref":{"$link":"` + legacyCID + `"},"mimeType":"image/jpeg","size":123}}}`, "https://img.example.test/img/embed_thumbnail/plain/" + thread.community + "/" + legacyCID}, + {"image", "at://" + thread.caller + "/social.coves.community.postv2/thumb-image", `{"$type":"social.coves.embed.images","images":[{"image":{"$type":"blob","ref":{"$link":"` + imageCID + `"},"mimeType":"image/jpeg","size":123},"alt":"First image"},{"image":{"$type":"blob","ref":{"$link":"` + firstCID + `"},"mimeType":"image/jpeg","size":123},"alt":"Second image"}]}`, "https://img.example.test/img/content_preview/plain/" + thread.caller + "/" + imageCID}, + {"text", "at://" + thread.caller + "/social.coves.community.postv2/thumb-text", "", ""}, + } + voter := "did:plc:thumbnailvoter" + f.id + f.addUser(voter, "thumbnailvoter"+f.id+".test", "Voter") + for index, post := range postsToCheck { + f.addPost(post.uri, "bafy"+post.key, "thumb-"+post.key, thread.caller, thread.community, "Title "+post.key, "Body", at) + if post.embed != "" { + _, err := f.db.ExecContext(context.Background(), `UPDATE posts SET embed = $1 WHERE uri = $2`, post.embed, post.uri) + require.NoError(t, err) + } + replyURI := "at://" + thread.actor + "/social.coves.community.comment/thumb-reply-" + post.key + f.addComment(replyURI, "bafyre"+post.key, "thumb-reply-"+post.key, thread.actor, post.uri, "bafy"+post.key, post.uri, "bafy"+post.key, "Reply", at) + f.addReply(thread.caller, notifications.ReasonPostReply, replyURI, "bafyre"+post.key, thread.actor, post.uri, post.uri, at, at.Add(time.Duration(index*3)*time.Second)) + f.addUpvoteGroup(thread.caller, post.uri, post.uri, at.Add(time.Duration(index*3+1)*time.Second)) + f.addUpvoteVote(voter, post.uri, "up", at) + f.addMention(thread.caller, post.uri, "bafy"+post.key, thread.caller, post.uri, at, at.Add(time.Duration(index*3+2)*time.Second)) + } + // Comments can store the very same images embed, but neither a comment + // subject nor a comment record may acquire a thumbnail. + commentSubject := "at://" + thread.caller + "/social.coves.community.comment/thumb-comment-subject" + commentRecord := "at://" + thread.actor + "/social.coves.community.comment/thumb-comment-record" + f.addComment(commentSubject, "bafycs", "thumb-comment-subject", thread.caller, postsToCheck[0].uri, "bafylink", postsToCheck[0].uri, "bafylink", "Subject", at) + f.addComment(commentRecord, "bafycr", "thumb-comment-record", thread.actor, postsToCheck[0].uri, "bafylink", commentSubject, "bafycs", "Reply", at) + for _, uri := range []string{commentSubject, commentRecord} { + _, err := f.db.ExecContext(context.Background(), `UPDATE comments SET embed = $1 WHERE uri = $2`, postsToCheck[2].embed, uri) + require.NoError(t, err) + } + f.addReply(thread.caller, notifications.ReasonCommentReply, commentRecord, "bafycr", thread.actor, commentSubject, postsToCheck[0].uri, at, at.Add(13*time.Second)) + f.addMention(thread.caller, commentRecord, "bafycr", thread.actor, postsToCheck[0].uri, at, at.Add(14*time.Second)) + f.addUpvoteGroup(thread.caller, commentSubject, postsToCheck[0].uri, at.Add(15*time.Second)) + f.addUpvoteVote(voter, commentSubject, "up", at) + + uris := make([]string, 0, len(postsToCheck)) + for _, post := range postsToCheck { + uris = append(uris, post.uri) + } + views, err := postgres.NewPostRepository(f.db).GetViewsByURIs(context.Background(), uris, "") + require.NoError(t, err) + feedURLs := map[string]string{} + for _, post := range postsToCheck { + view := views[post.uri] + require.NotNil(t, view, "feed view for %s", post.uri) + posts.TransformBlobRefsToURLs(view) + if post.embed == "" { + require.Nil(t, view.Embed) + continue + } + embed, ok := view.Embed.(map[string]interface{}) + require.True(t, ok) + var url string + if post.key == "image" { + require.Equal(t, "social.coves.embed.images#view", embed["$type"]) + images, ok := embed["images"].([]interface{}) + require.True(t, ok) + require.Len(t, images, 2) + url, ok = images[0].(map[string]interface{})["thumb"].(string) + require.True(t, ok) + } else { + require.Equal(t, "social.coves.embed.external#view", embed["$type"]) + url, ok = embed["external"].(map[string]interface{})["thumb"].(string) + require.True(t, ok) + } + require.Equal(t, post.literal, url, "literal proxy URL and owner for %s", post.key) + feedURLs[post.uri] = url + } + + response := f.request(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 15) + counts := map[string]map[string]int{} + for _, post := range postsToCheck { + counts[post.uri] = map[string]int{} + } + commentPositions := 0 + for _, row := range rows { + root := placeholderObject(t, row, "rootPost") + var rootURI string + require.NoError(t, json.Unmarshal(root["uri"], &rootURI)) + _, known := counts[rootURI] + require.True(t, known, "unexpected root %s", rootURI) + check := func(position string, object map[string]json.RawMessage, uri string) { + t.Helper() + if uri == commentSubject || uri == commentRecord { + require.NotContains(t, object, "thumbnail", "comment %s %s", uri, position) + commentPositions++ + return + } + if uri == postsToCheck[3].uri { + require.NotContains(t, object, "thumbnail", "text post at %s", position) + } else { + require.JSONEq(t, `"`+feedURLs[uri]+`"`, string(object["thumbnail"]), "%s %s matches feed projection", uri, position) + } + counts[uri][position]++ + } + check("rootPost", root, rootURI) + if string(row["reason"]) == `"mention"` { + record := placeholderObject(t, row, "record") + var uri string + require.NoError(t, json.Unmarshal(record["uri"], &uri)) + check("record", record, uri) + } else { + subject := placeholderObject(t, row, "subject") + var uri string + require.NoError(t, json.Unmarshal(subject["uri"], &uri)) + check("subject", subject, uri) + if string(row["reason"]) == `"commentReply"` { + check("record", placeholderObject(t, row, "record"), commentRecord) + } + } + } + for _, post := range postsToCheck { + rootCount := 3 + if post.key == "link" { + rootCount = 6 // The three comment notifications share this root. + } + require.Equal(t, map[string]int{"rootPost": rootCount, "subject": 2, "record": 1}, counts[post.uri], post.key) + } + require.Equal(t, 4, commentPositions, "comment reply subject and record, mention record, and upvote subject") +} diff --git a/internal/api/handlers/notification/list_notifications_upvote_test.go b/internal/api/handlers/notification/list_notifications_upvote_test.go new file mode 100644 index 0000000..eaf8577 --- /dev/null +++ b/internal/api/handlers/notification/list_notifications_upvote_test.go @@ -0,0 +1,389 @@ +//go:build integration + +package notification_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/users" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func (f *listHandlerFixture) addUpvoteGroup(recipient, subject, root string, sortAt time.Time) { + f.t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, 'upvote', NULL, NULL, NULL, $2, $3, NULL, $4)`, recipient, subject, root, sortAt.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) +} + +// Both timestamps order votes identically; the assertion does not pick a clock. +func (f *listHandlerFixture) addUpvoteVote(voter, subject, direction string, at time.Time) string { + f.t.Helper() + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + _, err := f.db.ExecContext(context.Background(), `INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at) + VALUES ($1, 'bafyvote', $2, $3, $4, 'bafystalesubject', $5, $6, $6)`, + uri, key, voter, subject, direction, at.UTC().Truncate(time.Microsecond)) + require.NoError(f.t, err) + return uri +} + +func upvoteVoters(t *testing.T, raw map[string]json.RawMessage) []map[string]json.RawMessage { + t.Helper() + var voters []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw["recentUpvoters"], &voters)) + return voters +} + +func upvoteVoterDIDs(t *testing.T, raw map[string]json.RawMessage) []string { + t.Helper() + var dids []string + for _, voter := range upvoteVoters(t, raw) { + var did string + require.NoError(t, json.Unmarshal(voter["did"], &did)) + dids = append(dids, did) + } + return dids +} + +func TestListNotifications_UpvoteFullShape(t *testing.T) { + for _, tc := range []struct { + name, kind, title, content, preview, subjectCID, rootCID string + }{ + {"post title", "post", "A complete title", "Body", "A complete title", "bafyroot", "bafyroot"}, + {"untitled post body", "post", "", strings.Repeat("b", 139) + "👩‍👩‍👧‍👦" + strings.Repeat("c", 160), strings.Repeat("b", 139) + "👩‍👩‍👧‍👦", "bafyroot", "bafyroot"}, + {"comment excerpt on another author's root", "comment", "Other root", strings.Repeat("a", 139) + "🇺🇸" + strings.Repeat("z", 160), strings.Repeat("a", 139) + "🇺🇸", "bafycurrentcomment", "bafyotherroot"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Seed root", "Seed body") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + root, subject := thread.root, thread.root + if tc.kind == "post" { + _, err := f.db.ExecContext(context.Background(), `UPDATE posts SET title = $1, content = $2 WHERE uri = $3`, tc.title, tc.content, thread.root) + require.NoError(t, err) + } else { + root = "at://" + thread.actor + "/social.coves.community.postv2/other" + f.addPost(root, tc.rootCID, "other", thread.actor, thread.community, tc.title, "Other body", at) + subject = "at://" + thread.caller + "/social.coves.community.comment/own" + f.addComment(subject, tc.subjectCID, "own", thread.caller, root, "bafystaleroot", root, "bafystaleparent", tc.content, at) + } + f.addUpvoteGroup(thread.caller, subject, root, at) + older, newest := "did:plc:older"+f.id, "did:plc:newest"+f.id + f.addUser(older, "older"+f.id+".test", "Older") + f.addUser(newest, "newest"+f.id+".test", "Newest") + f.addUpvoteVote(older, subject, "up", at.Add(time.Second)) + f.addUpvoteVote(newest, subject, "up", at.Add(2*time.Second)) + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "upvote group must be listed") + row := rows[0] + require.JSONEq(t, `"upvote"`, string(row["reason"])) + require.NotContains(t, row, "author") + require.NotContains(t, row, "record") + require.JSONEq(t, `{"uri":"`+subject+`","cid":"`+tc.subjectCID+`","preview":"`+tc.preview+`"}`, string(row["subject"])) + rootView := placeholderObject(t, row, "rootPost") + require.JSONEq(t, `"`+root+`"`, string(rootView["uri"])) + require.JSONEq(t, `"`+tc.rootCID+`"`, string(rootView["cid"])) + if tc.title == "" { + require.NotContains(t, rootView, "title") + } else { + require.JSONEq(t, `"`+tc.title+`"`, string(rootView["title"])) + } + require.JSONEq(t, `{"did":"`+thread.community+`","handle":"listcommunity`+f.id+`.coves.social","name":"Thread Community"}`, string(rootView["community"])) + require.JSONEq(t, `2`, string(row["upvoteCount"])) + require.Equal(t, []string{newest, older}, upvoteVoterDIDs(t, row)) + voters := upvoteVoters(t, row) + require.JSONEq(t, `{"did":"`+newest+`","handle":"newest`+f.id+`.test","displayName":"Newest"}`, mustJSON(t, voters[0])) + require.JSONEq(t, `{"did":"`+older+`","handle":"older`+f.id+`.test","displayName":"Older"}`, mustJSON(t, voters[1])) + }) + } +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) +} + +func TestListNotifications_UpvoteVoterExclusions(t *testing.T) { + for _, tc := range []struct { + name, exclusion string + count int + want []int + }{ + {"control", "", 5, []int{5, 4, 3}}, + {"recipient blocks V4", "recipientBlock", 4, []int{5, 3, 2}}, + {"V4 blocks recipient", "voterBlock", 4, []int{5, 3, 2}}, + {"V4 erased", "erased", 4, []int{5, 3, 2}}, + {"V4 aggregator", "aggregator", 4, []int{5, 3, 2}}, + {"V4 retracted", "retracted", 4, []int{5, 3, 2}}, + {"V4 downvote", "down", 4, []int{5, 3, 2}}, + {"V4 replaced by self vote", "self", 4, []int{5, 3, 2}}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root", "Body") + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + f.addUpvoteGroup(thread.caller, thread.root, thread.root, at) + voters := make([]string, 6) + for i := 1; i <= 5; i++ { + voters[i] = fmt.Sprintf("did:plc:voter%d%s", i, f.id) + f.addUser(voters[i], fmt.Sprintf("voter%d%s.test", i, f.id), fmt.Sprintf("Voter %d", i)) + if i == 4 && tc.exclusion == "self" { + voters[i] = thread.caller + } + direction := "up" + if i == 4 && tc.exclusion == "down" { + direction = "down" + } + vote := f.addUpvoteVote(voters[i], thread.root, direction, at.Add(time.Duration(i)*time.Second)) + if i == 4 && tc.exclusion == "retracted" { + _, err := f.db.ExecContext(context.Background(), `UPDATE votes SET deleted_at = $1 WHERE uri = $2`, at.Add(10*time.Second), vote) + require.NoError(t, err) + } + } + switch tc.exclusion { + case "recipientBlock", "voterBlock": + blocker, blocked := thread.caller, voters[4] + if tc.exclusion == "voterBlock" { + blocker, blocked = blocked, blocker + } + _, err := f.db.ExecContext(context.Background(), `INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) VALUES ($1, $2, $3, 'bafyblock')`, blocker, blocked, "at://"+blocker+"/social.coves.actor.block/"+testkit.TID()) + require.NoError(t, err) + case "erased": + _, err := f.db.ExecContext(context.Background(), `INSERT INTO deleted_accounts (did) VALUES ($1)`, voters[4]) + require.NoError(t, err) + case "aggregator": + _, err := f.db.ExecContext(context.Background(), `INSERT INTO aggregators (did, display_name, record_uri, record_cid) VALUES ($1, 'Voter aggregator', $2, 'bafyservice')`, voters[4], "at://"+voters[4]+"/social.coves.aggregator.service/self") + require.NoError(t, err) + } + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "group must remain visible with four qualifying voters") + require.JSONEq(t, fmt.Sprintf("%d", tc.count), string(rows[0]["upvoteCount"])) + require.Equal(t, []string{voters[tc.want[0]], voters[tc.want[1]], voters[tc.want[2]]}, upvoteVoterDIDs(t, rows[0])) + }) + } +} + +func TestListNotifications_UnindexedNewestUpvoter(t *testing.T) { + for _, indexed := range []bool{true, false} { + name := "unindexed newest" + if indexed { + name = "indexed newest" + } + t.Run(name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root", "Body") + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + f.addUpvoteGroup(thread.caller, thread.root, thread.root, at) + voters := make([]string, 6) + for i := 1; i <= 5; i++ { + voters[i] = fmt.Sprintf("did:plc:voter%d%s", i, f.id) + if i != 5 || indexed { + f.addUser(voters[i], fmt.Sprintf("voter%d%s.test", i, f.id), fmt.Sprintf("Voter %d", i)) + } + f.addUpvoteVote(voters[i], thread.root, "up", at.Add(time.Duration(i)*time.Second)) + } + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1) + require.JSONEq(t, `5`, string(rows[0]["upvoteCount"])) + require.Equal(t, []string{voters[5], voters[4], voters[3]}, upvoteVoterDIDs(t, rows[0])) + if !indexed { + require.JSONEq(t, `{"did":"`+voters[5]+`"}`, mustJSON(t, upvoteVoters(t, rows[0])[0])) + } + }) + } +} + +func TestListNotifications_UpvotePlaceholders(t *testing.T) { + for _, tc := range []struct { + name, subjectKind, withdrawal, subjectStatus, rootStatus string + }{ + {"deleted comment subject", "comment", "", "deleted", ""}, + {"removed post subject and root", "post", "communityWithdrawal", "removedByModerator", "removedByModerator"}, + {"live comment under deleted root", "comment", "authorDelete", "", "deleted"}, + {"live comment under removed root", "comment", "communityWithdrawal", "", "removedByModerator"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Root title", "Private root body") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + subject, subjectCID := thread.root, "bafyroot" + if tc.subjectKind == "comment" { + subject, subjectCID = "at://"+thread.caller+"/social.coves.community.comment/own", "bafyowncomment" + f.addComment(subject, subjectCID, "own", thread.caller, thread.root, "bafystaleroot", thread.root, "bafystaleroot", "Visible own comment", at) + } + f.addUpvoteGroup(thread.caller, subject, thread.root, at) + v1, v2 := "did:plc:first"+f.id, "did:plc:second"+f.id + f.addUser(v1, "first"+f.id+".test", "First") + f.addUser(v2, "second"+f.id+".test", "Second") + f.addUpvoteVote(v1, subject, "up", at.Add(time.Second)) + f.addUpvoteVote(v2, subject, "up", at.Add(2*time.Second)) + if tc.subjectStatus == "deleted" { + f.softDeleteComment(subject) + } + if tc.withdrawal != "" { + f.withdrawPost(thread.root, tc.withdrawal) + } + response := f.placeholderRequest(thread.caller, "") + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Len(t, rows, 1, "upvote group survives withdrawal") + row := rows[0] + require.NotContains(t, row, "record") + require.NotContains(t, row, "author") + require.JSONEq(t, `2`, string(row["upvoteCount"])) + require.Equal(t, []string{v2, v1}, upvoteVoterDIDs(t, row)) + subjectView := placeholderObject(t, row, "subject") + rootView := placeholderObject(t, row, "rootPost") + require.JSONEq(t, `"`+subject+`"`, string(subjectView["uri"])) + require.JSONEq(t, `"`+subjectCID+`"`, string(subjectView["cid"])) + require.JSONEq(t, `"`+thread.root+`"`, string(rootView["uri"])) + require.JSONEq(t, `"bafyroot"`, string(rootView["cid"])) + if tc.subjectStatus != "" { + require.JSONEq(t, `"`+tc.subjectStatus+`"`, string(subjectView["status"])) + require.NotContains(t, subjectView, "preview") + } else { + require.NotContains(t, subjectView, "status") + preview := "Visible own comment" + if tc.subjectKind == "post" { + preview = "Root title" + } + require.JSONEq(t, `"`+preview+`"`, string(subjectView["preview"])) + } + if tc.rootStatus != "" { + require.JSONEq(t, `"`+tc.rootStatus+`"`, string(rootView["status"])) + require.NotContains(t, rootView, "title") + require.NotContains(t, rootView, "community") + } else { + require.NotContains(t, rootView, "status") + require.JSONEq(t, `"Root title"`, string(rootView["title"])) + require.Contains(t, rootView, "community") + } + }) + } +} + +type upvoteBatchProfiles struct { + *countedProfiles + dids []string +} + +func (p *upvoteBatchProfiles) GetByDIDs(ctx context.Context, dids []string) (map[string]*users.User, error) { + p.dids = append([]string(nil), dids...) + return p.countedProfiles.GetByDIDs(ctx, dids) +} + +func TestListNotifications_UpvoteMixedPageBatchesHydration(t *testing.T) { + for _, count := range []int{50, 8} { + t.Run(fmt.Sprintf("%d rows", count), func(t *testing.T) { + f := newListHandlerFixture(t) + thread := f.seedThread("Batch root", "Batch body") + at := time.Date(2026, 9, 20, 9, 1, 0, 0, time.UTC) + parent := "at://" + thread.caller + "/social.coves.community.comment/batchparent" + f.addComment(parent, "bafybatchparent", "batchparent", thread.caller, thread.root, "bafyroot", thread.root, "bafyroot", "Parent comment", at) + authors := []string{thread.actor, "did:plc:second" + f.id, "did:plc:third" + f.id} + for i, author := range authors[1:] { + f.addUser(author, fmt.Sprintf("author%d%s.test", i, f.id), fmt.Sprintf("Author %d", i)) + } + wantDIDs := map[string]bool{} + groups := map[string][]string{} + wantRecords := map[string]bool{} + for i := 0; i < count; i++ { + key := fmt.Sprintf("mixed%d", i) + moment := at.Add(time.Duration(i) * time.Second) + if i%4 == 0 { + root := "at://" + thread.caller + "/social.coves.community.postv2/" + key + f.addPost(root, "bafy"+key, key, thread.caller, thread.community, "Title "+key, "Body", at) + subject := root + if i%8 == 4 { + subject = "at://" + thread.caller + "/social.coves.community.comment/" + key + f.addComment(subject, "bafycomment"+key, key, thread.caller, root, "bafy"+key, root, "bafy"+key, "Own comment", at) + } + f.addUpvoteGroup(thread.caller, subject, root, moment) + for j := 0; j < 2; j++ { + voter := fmt.Sprintf("did:plc:voter%d_%d%s", i, j, f.id) + f.addUser(voter, fmt.Sprintf("voter%d_%d%s.test", i, j, f.id), "Batch voter") + f.addUpvoteVote(voter, subject, "up", moment.Add(time.Duration(j+1)*time.Millisecond)) + wantDIDs[voter] = true + groups[subject] = append([]string{voter}, groups[subject]...) + } + continue + } + actor := authors[i%len(authors)] + wantDIDs[actor] = true + uri := "at://" + actor + "/social.coves.community.comment/" + key + f.addComment(uri, "bafy"+key, key, actor, thread.root, "bafyroot", thread.root, "bafyroot", "Text "+key, moment) + switch i % 4 { + case 1: + f.addReply(thread.caller, notifications.ReasonPostReply, uri, "bafystored"+key, actor, thread.root, thread.root, moment, moment) + case 2: + f.addReply(thread.caller, notifications.ReasonCommentReply, uri, "bafystored"+key, actor, parent, thread.root, moment, moment) + case 3: + f.addMention(thread.caller, uri, "bafystored"+key, actor, thread.root, moment, moment) + } + wantRecords[uri] = true + } + repo := &countedNotificationReads{ReadRepository: f.repo} + profiles := &upvoteBatchProfiles{countedProfiles: &countedProfiles{ProfileLookup: postgres.NewUserRepository(f.db)}} + posts := &countedPosts{PostViewLookup: postgres.NewPostRepository(f.db)} + comments := &countedComments{CommentLookup: postgres.NewCommentRepository(f.db)} + response := f.requestWithListDependencies(thread.caller, fmt.Sprintf("limit=%d", count), repo, profiles, posts, comments) + require.Equal(t, http.StatusOK, response.status, "response: %s", response.raw) + rows := listRawRows(t, response) + require.Equal(t, count, len(rows), "no live row may be omitted") + for _, row := range rows { + root := placeholderObject(t, row, "rootPost") + require.Contains(t, root, "title") + if string(row["reason"]) == `"upvote"` { + subject := placeholderObject(t, row, "subject") + var uri string + require.NoError(t, json.Unmarshal(subject["uri"], &uri)) + require.Contains(t, groups, uri) + require.Contains(t, subject, "preview") + require.JSONEq(t, `2`, string(row["upvoteCount"])) + require.Equal(t, groups[uri], upvoteVoterDIDs(t, row)) + delete(groups, uri) + } else { + var record map[string]json.RawMessage + require.NoError(t, json.Unmarshal(row["record"], &record)) + var uri string + require.NoError(t, json.Unmarshal(record["uri"], &uri)) + require.True(t, wantRecords[uri], "unexpected record %s", uri) + require.Contains(t, record, "excerpt") + require.Contains(t, row, "author") + delete(wantRecords, uri) + } + } + require.Empty(t, groups) + require.Empty(t, wantRecords) + require.Equal(t, 1, repo.lists) + require.Equal(t, 1, profiles.calls) + require.Len(t, profiles.dids, len(wantDIDs)) + for did := range wantDIDs { + require.Contains(t, profiles.dids, did) + } + require.LessOrEqual(t, posts.calls, 1) + require.LessOrEqual(t, comments.calls, 1) + }) + } +} diff --git a/internal/api/handlers/notification/preferences.go b/internal/api/handlers/notification/preferences.go new file mode 100644 index 0000000..f87000e --- /dev/null +++ b/internal/api/handlers/notification/preferences.go @@ -0,0 +1,106 @@ +package notification + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "Coves/internal/api/middleware" + "Coves/internal/api/reqbody" + "Coves/internal/api/xrpc" + "Coves/internal/core/notifications" +) + +// PreferencesResponse is the getPreferences and putPreferences output. +type PreferencesResponse struct { + PostReply bool `json:"postReply"` + CommentReply bool `json:"commentReply"` + Mention bool `json:"mention"` + Upvote bool `json:"upvote"` +} + +// PreferencesHandler serves the notification preferences endpoints. +type PreferencesHandler struct { + service notifications.PreferencesService +} + +// NewPreferencesHandler builds the preferences handler. +func NewPreferencesHandler(service notifications.PreferencesService) *PreferencesHandler { + return &PreferencesHandler{service: service} +} + +// HandleGetPreferences serves social.coves.notification.getPreferences. +func (h *PreferencesHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) { + userDID := middleware.GetUserDID(r) + if userDID == "" { + xrpc.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + preferences, err := h.service.GetPreferences(r.Context(), userDID) + if err != nil { + slog.ErrorContext(r.Context(), "failed to get notification preferences", "error", err) + xrpc.WriteError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + return + } + xrpc.WriteJSON(w, http.StatusOK, preferencesResponse(preferences)) +} + +// HandlePutPreferences serves social.coves.notification.putPreferences. +func (h *PreferencesHandler) HandlePutPreferences(w http.ResponseWriter, r *http.Request) { + userDID := middleware.GetUserDID(r) + if userDID == "" { + xrpc.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + var values map[string]json.RawMessage + if !xrpc.DecodeJSON(w, r, reqbody.LimitTiny, &values) { + return + } + if values == nil { + xrpc.WriteError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + var update notifications.PreferencesUpdate + for _, field := range []struct { + name string + target **bool + }{ + {"postReply", &update.PostReply}, + {"commentReply", &update.CommentReply}, + {"mention", &update.Mention}, + {"upvote", &update.Upvote}, + } { + raw, present := values[field.name] + if !present { + continue + } + var enabled *bool + if err := json.Unmarshal(raw, &enabled); err != nil || enabled == nil { + xrpc.WriteError(w, http.StatusBadRequest, "InvalidRequest", "Preference values must be booleans") + return + } + *field.target = enabled + } + preferences, err := h.service.PutPreferences(r.Context(), userDID, update) + if errors.Is(err, notifications.ErrAccountNotIndexed) { + slog.WarnContext(r.Context(), "notification preferences rejected: account not indexed") + xrpc.WriteError(w, http.StatusBadRequest, "AccountNotIndexed", "Account is not indexed") + return + } + if err != nil { + slog.ErrorContext(r.Context(), "failed to put notification preferences", "error", err) + xrpc.WriteError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + return + } + xrpc.WriteJSON(w, http.StatusOK, preferencesResponse(preferences)) +} + +func preferencesResponse(preferences notifications.Preferences) PreferencesResponse { + return PreferencesResponse{ + PostReply: preferences.PostReply, + CommentReply: preferences.CommentReply, + Mention: preferences.Mention, + Upvote: preferences.Upvote, + } +} diff --git a/internal/api/handlers/notification/preferences_test.go b/internal/api/handlers/notification/preferences_test.go new file mode 100644 index 0000000..7c63416 --- /dev/null +++ b/internal/api/handlers/notification/preferences_test.go @@ -0,0 +1,235 @@ +//go:build integration + +package notification_test + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + "time" + + "Coves/internal/api/handlers/notification" + "Coves/internal/api/middleware" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func preferenceHandlerUsers(t *testing.T, db *sql.DB) (string, string) { + t.Helper() + id := testkit.UniqueID(t) + caller, other := "did:plc:prefcaller"+id, "did:plc:prefother"+id + for _, user := range []struct{ did, handle string }{ + {caller, "prefcaller" + id + ".test"}, + {other, "prefother" + id + ".test"}, + } { + _, err := db.Exec(`INSERT INTO users (did, handle, pds_url) VALUES ($1, $2, $3)`, user.did, user.handle, "https://pds.test") + require.NoError(t, err) + } + return caller, other +} + +func preferenceHandlerState(t *testing.T, db *sql.DB, did string) []string { + t.Helper() + var disabled []string + require.NoError(t, db.QueryRow(`SELECT disabled_reasons FROM notification_state WHERE did = $1`, did).Scan(pq.Array(&disabled))) + sort.Strings(disabled) + return disabled +} + +func putPreferencesRequest(did, body string) (*httptest.ResponseRecorder, *http.Request) { + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.notification.putPreferences", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(middleware.SetTestUserDID(req.Context(), did)) + return httptest.NewRecorder(), req +} + +func TestGetPreferences_ReturnsCallersPreferences(t *testing.T) { + db := testkit.DB(t) + caller, other := preferenceHandlerUsers(t, db) + for _, state := range []struct { + did string + disabled []string + }{ + {caller, []string{"postReply"}}, + {other, []string{"mention"}}, + } { + _, err := db.Exec(`INSERT INTO notification_state (did, disabled_reasons) VALUES ($1, $2)`, state.did, pq.Array(state.disabled)) + require.NoError(t, err) + } + repo := postgres.NewNotificationRepository(db) + handler := notification.NewPreferencesHandler(notifications.NewPreferencesService(repo.(notifications.PreferencesRepository))) + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.notification.getPreferences", nil) + req = req.WithContext(middleware.SetTestUserDID(req.Context(), caller)) + rec := httptest.NewRecorder() + handler.HandleGetPreferences(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "getPreferences response: %s", rec.Body.String()) + require.JSONEq(t, `{"postReply":false,"commentReply":true,"mention":true,"upvote":true}`, rec.Body.String()) +} + +func TestPutPreferences_ReturnsFullPreferencesForCaller(t *testing.T) { + db := testkit.DB(t) + caller, other := preferenceHandlerUsers(t, db) + _, err := db.Exec(`INSERT INTO notification_state (did, disabled_reasons) VALUES ($1, $2)`, other, pq.Array([]string{"upvote"})) + require.NoError(t, err) + repo := postgres.NewNotificationRepository(db) + handler := notification.NewPreferencesHandler(notifications.NewPreferencesService(repo.(notifications.PreferencesRepository))) + rec, req := putPreferencesRequest(caller, `{"commentReply":false}`) + handler.HandlePutPreferences(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "putPreferences response: %s", rec.Body.String()) + require.JSONEq(t, `{"postReply":true,"commentReply":false,"mention":true,"upvote":true}`, rec.Body.String()) + require.Equal(t, []string{"commentReply"}, preferenceHandlerState(t, db, caller)) + require.Equal(t, []string{"upvote"}, preferenceHandlerState(t, db, other)) +} + +func TestPutPreferences_RejectsNonBooleanValues(t *testing.T) { + for _, tc := range []struct{ name, body string }{ + {"string false", `{"mention":"false"}`}, + {"number", `{"mention":1}`}, + {"null", `{"mention":null}`}, + {"mixed valid and invalid", `{"mention":false,"upvote":"no"}`}, + {"truncated JSON", `{"mention":fal`}, + {"null body", `null`}, + {"trailing data", `{"mention":false}{"upvote":false}`}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + caller, _ := preferenceHandlerUsers(t, db) + _, err := db.Exec(`INSERT INTO notification_state (did, disabled_reasons) VALUES ($1, $2)`, caller, pq.Array([]string{"upvote"})) + require.NoError(t, err) + repo := postgres.NewNotificationRepository(db) + handler := notification.NewPreferencesHandler(notifications.NewPreferencesService(repo.(notifications.PreferencesRepository))) + rec, req := putPreferencesRequest(caller, tc.body) + handler.HandlePutPreferences(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code, "putPreferences response: %s", rec.Body.String()) + var response map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + require.Equal(t, "InvalidRequest", response["error"]) + require.Equal(t, []string{"upvote"}, preferenceHandlerState(t, db, caller)) + }) + } +} + +func TestPutPreferences_RejectsOverLimitBodyAsPayloadTooLarge(t *testing.T) { + db := testkit.DB(t) + caller, _ := preferenceHandlerUsers(t, db) + _, err := db.Exec(`INSERT INTO notification_state (did, disabled_reasons) VALUES ($1, $2)`, caller, pq.Array([]string{"upvote"})) + require.NoError(t, err) + repo := postgres.NewNotificationRepository(db) + handler := notification.NewPreferencesHandler(notifications.NewPreferencesService(repo.(notifications.PreferencesRepository))) + rec, req := putPreferencesRequest(caller, `{"mention":false,"padding":"`+strings.Repeat("x", 5000)+`"}`) + handler.HandlePutPreferences(rec, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, "putPreferences response: %s", rec.Body.String()) + var response map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + require.Equal(t, "PayloadTooLarge", response["error"]) + require.Equal(t, []string{"upvote"}, preferenceHandlerState(t, db, caller)) +} + +func TestPutPreferences_UnindexedCallerGetsAccountNotIndexed(t *testing.T) { + db := testkit.DB(t) + caller := "did:plc:prefunindexed" + testkit.UniqueID(t) + repo := postgres.NewNotificationRepository(db) + handler := notification.NewPreferencesHandler(notifications.NewPreferencesService(repo.(notifications.PreferencesRepository))) + for _, body := range []string{`{}`, `{"mention":false}`} { + rec, req := putPreferencesRequest(caller, body) + handler.HandlePutPreferences(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code, "putPreferences %s response: %s", body, rec.Body.String()) + var response map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + require.Equal(t, "AccountNotIndexed", response["error"], "putPreferences %s", body) + } + var stateRows int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM notification_state WHERE did = $1`, caller).Scan(&stateRows)) + require.Zero(t, stateRows) +} + +func TestNotificationPreferences_DisablingUpvoteHidesGroupsFromUnreadCount(t *testing.T) { + db := testkit.DB(t) + caller, other := preferenceHandlerUsers(t, db) + id := testkit.UniqueID(t) + base := time.Date(2026, time.September, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + _, err := db.Exec(`INSERT INTO notification_state (did, seen_at) VALUES ($1, $2)`, caller, base.Add(-time.Second)) + require.NoError(t, err) + community := "did:plc:prefcommunity" + id + _, err = db.Exec(`INSERT INTO communities + (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) + VALUES ($1, $2, 'preferences', $3, $3, $3, $4)`, community, "pref"+id+".coves.social", caller, base) + require.NoError(t, err) + root := "at://" + caller + "/social.coves.community.postv2/" + id + rootCID := "bafyprefpost" + id + _, err = db.Exec(`INSERT INTO posts + (uri, cid, rkey, author_did, community_did, title, created_at, score, upvote_count, downvote_count) + VALUES ($1, $2, $3, $4, $5, 'preferences root', $6, 1, 1, 0)`, root, rootCID, id, caller, community, base) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO community_post_admissions + (community_did, post_uri, status, acceptance_uri, acceptance_rkey, accepted_cid, evaluated_cid, + last_community_rev, last_community_op_rank, created_at, updated_at) + VALUES ($1, $2, 'accepted', $3, $4, $5, $5, '3lqqqqqqqqqq2', $7, $6, $6)`, + community, root, "at://"+community+"/social.coves.community.acceptance/"+id, id, rootCID, base, int16(posts.CommunityOpPut)) + require.NoError(t, err) + for _, suffix := range []string{"subject", "reply"} { + rkey := id + suffix + parent := root + if suffix == "reply" { + parent = "at://" + other + "/social.coves.community.comment/" + id + "subject" + } + _, err := db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $6, 'reply', $8)`, + "at://"+other+"/social.coves.community.comment/"+rkey, "bafyprefcomment"+rkey, rkey, other, root, rootCID, parent, base) + require.NoError(t, err) + } + subject := "at://" + other + "/social.coves.community.comment/" + id + "subject" + reply := "at://" + other + "/social.coves.community.comment/" + id + "reply" + _, err = db.Exec(`INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, 'commentReply', $2, $3, $4, $5, $6, $7, $7)`, + caller, reply, "bafyprefcomment"+id+"reply", other, subject, root, base.Add(time.Second)) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at) + VALUES ($1, 'bafyprefvote', $2, $3, $4, $5, 'up', $6)`, + "at://"+other+"/social.coves.feed.vote/"+id, id, other, root, rootCID, base) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO notifications + (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $2, $3)`, caller, root, base.Add(2*time.Second)) + require.NoError(t, err) + + repo := postgres.NewNotificationRepository(db) + unread := notification.NewHandler(notifications.NewService(repo.(notifications.ReadRepository))) + preferences := notification.NewPreferencesHandler(notifications.NewPreferencesService(repo.(notifications.PreferencesRepository))) + getCount := func() *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.notification.getUnreadCount", nil) + req = req.WithContext(middleware.SetTestUserDID(req.Context(), caller)) + rec := httptest.NewRecorder() + unread.HandleGetUnreadCount(rec, req) + return rec + } + before := getCount() + require.Equal(t, http.StatusOK, before.Code, "getUnreadCount response: %s", before.Body.String()) + require.JSONEq(t, `{"count":2}`, before.Body.String(), "fixture must start with a qualifying upvote group and a visible reply") + + putResponse, putRequest := putPreferencesRequest(caller, `{"upvote":false}`) + preferences.HandlePutPreferences(putResponse, putRequest) + require.Equal(t, http.StatusOK, putResponse.Code, "putPreferences response: %s", putResponse.Body.String()) + require.JSONEq(t, `{"postReply":true,"commentReply":true,"mention":true,"upvote":false}`, putResponse.Body.String()) + after := getCount() + require.Equal(t, http.StatusOK, after.Code, "getUnreadCount response: %s", after.Body.String()) + require.JSONEq(t, `{"count":1}`, after.Body.String()) +} diff --git a/internal/api/handlers/notification/unread_count.go b/internal/api/handlers/notification/unread_count.go new file mode 100644 index 0000000..0a58ea1 --- /dev/null +++ b/internal/api/handlers/notification/unread_count.go @@ -0,0 +1,37 @@ +package notification + +import ( + "log/slog" + "net/http" + + "Coves/internal/api/middleware" + "Coves/internal/api/xrpc" + "Coves/internal/core/notifications" +) + +// UnreadCountResponse is the getUnreadCount output. +type UnreadCountResponse struct { + Count int `json:"count"` +} + +// Handler serves social.coves.notification.* endpoints. +type Handler struct{ service notifications.Service } + +// NewHandler builds the notification handler. +func NewHandler(service notifications.Service) *Handler { return &Handler{service: service} } + +// HandleGetUnreadCount serves social.coves.notification.getUnreadCount. +func (h *Handler) HandleGetUnreadCount(w http.ResponseWriter, r *http.Request) { + userDID := middleware.GetUserDID(r) + if userDID == "" { + xrpc.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + count, err := h.service.CountUnread(r.Context(), userDID) + if err != nil { + slog.ErrorContext(r.Context(), "failed to count unread notifications", "error", err) + xrpc.WriteError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + return + } + xrpc.WriteJSON(w, http.StatusOK, UnreadCountResponse{Count: count}) +} diff --git a/internal/api/handlers/notification/unread_count_test.go b/internal/api/handlers/notification/unread_count_test.go new file mode 100644 index 0000000..b197462 --- /dev/null +++ b/internal/api/handlers/notification/unread_count_test.go @@ -0,0 +1,115 @@ +//go:build integration + +package notification_test + +import ( + "context" + "database/sql" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "Coves/internal/api/handlers/notification" + "Coves/internal/api/middleware" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + os.Exit(testkit.Main(m, testkit.RequirePostgres)) +} + +// Seed the reader's two recipients with the same accepted root; only the +// authenticated caller's three notifications may contribute to the response. +func seedUnreadHandlerFixture(t *testing.T, db *sql.DB) (string, string) { + t.Helper() + ctx := context.Background() + id := testkit.UniqueID(t) + caller, other := "did:plc:unreadcaller"+id, "did:plc:unreadother"+id + for _, user := range []struct{ did, handle string }{ + {caller, "unreadcaller" + id + ".test"}, + {other, "unreadother" + id + ".test"}, + } { + _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle, pds_url) VALUES ($1, $2, $3)`, + user.did, user.handle, "https://pds.test") + require.NoError(t, err) + } + base := time.Date(2026, time.September, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + for _, did := range []string{caller, other} { + _, err := db.ExecContext(ctx, `INSERT INTO notification_state (did, seen_at) VALUES ($1, $2)`, did, base.Add(-time.Second)) + require.NoError(t, err) + } + community := "did:plc:unreadcommunity" + id + _, err := db.ExecContext(ctx, `INSERT INTO communities + (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) + VALUES ($1, $2, $3, $4, $4, $4, $5)`, community, "unread"+id+".coves.social", "unread", caller, base) + require.NoError(t, err) + root := "at://" + caller + "/social.coves.community.postv2/" + id + rootCID := "bafyunreadpost" + id + _, err = db.ExecContext(ctx, `INSERT INTO posts + (uri, cid, rkey, author_did, community_did, title, created_at, score, upvote_count, downvote_count) + VALUES ($1, $2, $3, $4, $5, 'unread root', $6, 1, 1, 0)`, root, rootCID, id, caller, community, base) + require.NoError(t, err) + _, err = db.ExecContext(ctx, `INSERT INTO community_post_admissions + (community_did, post_uri, status, acceptance_uri, acceptance_rkey, accepted_cid, evaluated_cid, + last_community_rev, last_community_op_rank, created_at, updated_at) + VALUES ($1, $2, 'accepted', $3, $4, $5, $5, '3lqqqqqqqqqq2', $7, $6, $6)`, + community, root, "at://"+community+"/social.coves.community.acceptance/"+id, id, rootCID, base, int16(posts.CommunityOpPut)) + require.NoError(t, err) + + seedComment := func(rkey, parent string, at time.Time) string { + t.Helper() + uri := "at://" + other + "/social.coves.community.comment/" + rkey + _, err := db.ExecContext(ctx, `INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $6, 'reply', $8)`, + uri, "bafyunreadcomment"+rkey, rkey, other, root, rootCID, parent, at) + require.NoError(t, err) + return uri + } + subject := seedComment(id+"subject", root, base) + for _, recipient := range []struct { + did string + count int + }{ + {caller, 3}, {other, 5}, + } { + for i := 0; i < recipient.count; i++ { + rkey := testkit.UniqueID(t) + at := base.Add(time.Duration(i+1) * time.Second) + reason, target := "postReply", root + if i%2 == 0 { + reason, target = "commentReply", subject + } + record := seedComment(rkey, target, at) + _, err := db.ExecContext(ctx, `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`, + recipient.did, reason, record, "bafyunreadcomment"+rkey, other, target, root, at) + require.NoError(t, err) + } + } + return caller, other +} + +func TestGetUnreadCount_UsesAuthenticatedRecipient(t *testing.T) { + db := testkit.DB(t) + caller, other := seedUnreadHandlerFixture(t, db) + repo := postgres.NewNotificationRepository(db).(notifications.ReadRepository) + handler := notification.NewHandler(notifications.NewService(repo)) + req := httptest.NewRequest(http.MethodGet, + "/xrpc/social.coves.notification.getUnreadCount?recipient="+other+"&did="+other, nil) + req = req.WithContext(middleware.SetTestUserDID(req.Context(), caller)) + rec := httptest.NewRecorder() + handler.HandleGetUnreadCount(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "getUnreadCount response: %s", rec.Body.String()) + require.JSONEq(t, `{"count":3}`, rec.Body.String()) + require.Contains(t, rec.Header().Get("Content-Type"), "application/json") +} diff --git a/internal/api/handlers/notification/update_seen.go b/internal/api/handlers/notification/update_seen.go new file mode 100644 index 0000000..91ce355 --- /dev/null +++ b/internal/api/handlers/notification/update_seen.go @@ -0,0 +1,45 @@ +package notification + +import ( + "errors" + "log/slog" + "net/http" + + "Coves/internal/api/middleware" + "Coves/internal/api/reqbody" + "Coves/internal/api/xrpc" + "Coves/internal/core/notifications" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// HandleUpdateSeen serves social.coves.notification.updateSeen. +func (h *Handler) HandleUpdateSeen(w http.ResponseWriter, r *http.Request) { + userDID := middleware.GetUserDID(r) + if userDID == "" { + xrpc.WriteError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + var input struct { + SeenAt string `json:"seenAt"` + } + if !xrpc.DecodeJSON(w, r, reqbody.LimitTiny, &input) { + return + } + seenAt, err := syntax.ParseDatetimeTime(input.SeenAt) + if err != nil { + xrpc.WriteError(w, http.StatusBadRequest, "InvalidRequest", "Invalid seenAt datetime") + return + } + if err := h.service.UpdateSeen(r.Context(), userDID, seenAt); err != nil { + if errors.Is(err, notifications.ErrAccountNotIndexed) { + slog.WarnContext(r.Context(), "notification seen update rejected: account not indexed") + xrpc.WriteError(w, http.StatusBadRequest, "AccountNotIndexed", "Account is not indexed") + return + } + slog.ErrorContext(r.Context(), "failed to update notification seen time", "error", err) + xrpc.WriteError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/internal/api/handlers/notification/update_seen_test.go b/internal/api/handlers/notification/update_seen_test.go new file mode 100644 index 0000000..52bab21 --- /dev/null +++ b/internal/api/handlers/notification/update_seen_test.go @@ -0,0 +1,124 @@ +//go:build integration + +package notification_test + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "Coves/internal/api/handlers/notification" + "Coves/internal/api/middleware" + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func updateSeenRequest(did, body string) (*httptest.ResponseRecorder, *http.Request) { + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.notification.updateSeen", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(middleware.SetTestUserDID(req.Context(), did)) + return httptest.NewRecorder(), req +} + +func updateSeenHandler(db *sql.DB) *notification.Handler { + return notification.NewHandler(notifications.NewService(postgres.NewNotificationRepository(db).(notifications.ReadRepository))) +} + +func requireUpdateSeenError(t *testing.T, rec *httptest.ResponseRecorder, status int, name string) { + t.Helper() + require.Equal(t, status, rec.Code, "updateSeen response: %s", rec.Body.String()) + var response map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + require.Equal(t, name, response["error"]) +} + +func requireNoNotificationState(t *testing.T, db *sql.DB, did string) { + t.Helper() + var rows int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM notification_state WHERE did = $1`, did).Scan(&rows)) + require.Zero(t, rows, "updateSeen must not create notification state for %s", did) +} + +func TestUpdateSeen_StoresCallersSeenAt(t *testing.T) { + db := testkit.DB(t) + caller, other := preferenceHandlerUsers(t, db) + handler := updateSeenHandler(db) + rec, req := updateSeenRequest(caller, `{"seenAt":"2026-09-30T14:34:56.123456+02:00"}`) + handler.HandleUpdateSeen(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "updateSeen response: %s", rec.Body.String()) + var seenAt time.Time + require.NoError(t, db.QueryRow(`SELECT seen_at FROM notification_state WHERE did = $1`, caller).Scan(&seenAt)) + require.True(t, seenAt.Equal(time.Date(2026, 9, 30, 12, 34, 56, 123456000, time.UTC)), "stored seen_at: %s", seenAt) + requireNoNotificationState(t, db, other) +} + +// Postgres accepts zone offsets only up to ±15:59; datetime syntax allows up +// to ±23:59, so the instant must reach the database independent of its zone. +func TestUpdateSeen_StoresSeenAtWithOffsetBeyondPostgresZoneRange(t *testing.T) { + db := testkit.DB(t) + caller, _ := preferenceHandlerUsers(t, db) + handler := updateSeenHandler(db) + rec, req := updateSeenRequest(caller, `{"seenAt":"2026-09-30T12:00:00+16:00"}`) + handler.HandleUpdateSeen(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "updateSeen response: %s", rec.Body.String()) + var seenAt time.Time + require.NoError(t, db.QueryRow(`SELECT seen_at FROM notification_state WHERE did = $1`, caller).Scan(&seenAt)) + require.True(t, seenAt.Equal(time.Date(2026, 9, 29, 20, 0, 0, 0, time.UTC)), "stored seen_at: %s", seenAt) +} + +func TestUpdateSeen_RejectsMissingOrInvalidSeenAt(t *testing.T) { + for _, tc := range []struct{ name, body string }{ + {"missing", `{}`}, + {"null", `{"seenAt":null}`}, + {"unparseable", `{"seenAt":"yesterday"}`}, + {"without zone", `{"seenAt":"2026-09-30T12:00:00"}`}, + {"negative zero offset", `{"seenAt":"2026-09-30T12:00:00-00:00"}`}, + {"non-string", `{"seenAt":1727697600}`}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + caller, _ := preferenceHandlerUsers(t, db) + handler := updateSeenHandler(db) + rec, req := updateSeenRequest(caller, tc.body) + handler.HandleUpdateSeen(rec, req) + + requireUpdateSeenError(t, rec, http.StatusBadRequest, "InvalidRequest") + requireNoNotificationState(t, db, caller) + }) + } +} + +func TestUpdateSeen_UnindexedCallerGetsAccountNotIndexed(t *testing.T) { + db := testkit.DB(t) + caller := "did:plc:seenunindexed" + testkit.UniqueID(t) + handler := updateSeenHandler(db) + rec, req := updateSeenRequest(caller, `{"seenAt":"2026-09-30T12:00:00Z"}`) + handler.HandleUpdateSeen(rec, req) + + requireUpdateSeenError(t, rec, http.StatusBadRequest, "AccountNotIndexed") + requireNoNotificationState(t, db, caller) +} + +func TestUpdateSeen_DatabaseFailureIsInternalError(t *testing.T) { + db := testkit.DB(t) + caller, _ := preferenceHandlerUsers(t, db) + var name string + require.NoError(t, db.QueryRow(`SELECT current_database()`).Scan(&name)) + closedDB, err := sql.Open("postgres", testkit.Endpoints().Postgres.URL(name)) + require.NoError(t, err) + require.NoError(t, closedDB.Close()) + handler := updateSeenHandler(closedDB) + rec, req := updateSeenRequest(caller, `{"seenAt":"2026-09-30T12:00:00Z"}`) + handler.HandleUpdateSeen(rec, req) + + requireUpdateSeenError(t, rec, http.StatusInternalServerError, "InternalServerError") +} diff --git a/internal/api/routes/notification.go b/internal/api/routes/notification.go new file mode 100644 index 0000000..74f44a4 --- /dev/null +++ b/internal/api/routes/notification.go @@ -0,0 +1,29 @@ +package routes + +import ( + "Coves/internal/api/handlers/notification" + "Coves/internal/api/middleware" + "Coves/internal/core/notifications" + + "github.com/go-chi/chi/v5" +) + +// RegisterNotificationRoutes registers social.coves.notification.* endpoints. +func RegisterNotificationRoutes(r chi.Router, service notifications.Service, authMiddleware *middleware.OAuthAuthMiddleware) { + handler := notification.NewHandler(service) + r.With(authMiddleware.RequireAuth).Get("/xrpc/social.coves.notification.getUnreadCount", handler.HandleGetUnreadCount) + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.notification.updateSeen", handler.HandleUpdateSeen) +} + +// RegisterNotificationPreferenceRoutes registers the authenticated preferences endpoints. +func RegisterNotificationPreferenceRoutes(r chi.Router, service notifications.PreferencesService, authMiddleware *middleware.OAuthAuthMiddleware) { + handler := notification.NewPreferencesHandler(service) + r.With(authMiddleware.RequireAuth).Get("/xrpc/social.coves.notification.getPreferences", handler.HandleGetPreferences) + r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.notification.putPreferences", handler.HandlePutPreferences) +} + +// RegisterNotificationListRoutes registers the authenticated list endpoint. +func RegisterNotificationListRoutes(r chi.Router, service notifications.ListService, authMiddleware *middleware.OAuthAuthMiddleware) { + handler := notification.NewListHandler(service) + r.With(authMiddleware.RequireAuth).Get("/xrpc/social.coves.notification.listNotifications", handler.HandleListNotifications) +} diff --git a/internal/api/routes/notification_list_routes_test.go b/internal/api/routes/notification_list_routes_test.go new file mode 100644 index 0000000..c8e4c11 --- /dev/null +++ b/internal/api/routes/notification_list_routes_test.go @@ -0,0 +1,40 @@ +package routes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "Coves/internal/api/middleware" + "Coves/internal/core/notifications" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" +) + +type listRouteService struct{ reached bool } + +func (s *listRouteService) ListNotifications(context.Context, string, string, int) (notifications.ListNotificationsOutput, error) { + s.reached = true + return notifications.ListNotificationsOutput{}, nil +} + +func TestNotificationListRoute_RequiresAuth(t *testing.T) { + service := &listRouteService{} + unsealer := &probeUnsealer{seen: map[string]bool{}} + mux := chi.NewRouter() + RegisterNotificationListRoutes(mux, service, middleware.NewOAuthAuthMiddleware(unsealer, nil)) + routes := walkRoutes(t, mux) + key := routeKey{method: http.MethodGet, path: "/xrpc/social.coves.notification.listNotifications"} + chain, registered := routes[key] + require.True(t, registered, "GET listNotifications must be registered") + require.Len(t, routes, 1, "listNotifications registers exactly one GET route") + facts := chainFacts(chain, unsealer) + require.Equal(t, 1, countKind(facts, mwRequireAuth)) + require.Equal(t, 0, countKind(facts, mwOptionalAuth)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, key.path, nil)) + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.False(t, service.reached, "anonymous GET must not reach ListNotifications") +} diff --git a/internal/api/routes/notification_preferences_routes_test.go b/internal/api/routes/notification_preferences_routes_test.go new file mode 100644 index 0000000..592880c --- /dev/null +++ b/internal/api/routes/notification_preferences_routes_test.go @@ -0,0 +1,46 @@ +package routes + +import ( + "context" + "net/http" + "testing" + + "Coves/internal/api/middleware" + "Coves/internal/core/notifications" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" +) + +type preferenceRouteService struct{} + +var _ notifications.PreferencesService = preferenceRouteService{} + +func (preferenceRouteService) GetPreferences(context.Context, string) (notifications.Preferences, error) { + return notifications.Preferences{}, nil +} + +func (preferenceRouteService) PutPreferences(context.Context, string, notifications.PreferencesUpdate) (notifications.Preferences, error) { + return notifications.Preferences{}, nil +} + +func TestNotificationPreferenceRoutes_AuthenticatedOnly(t *testing.T) { + unsealer := &probeUnsealer{seen: map[string]bool{}} + mux := chi.NewRouter() + RegisterNotificationPreferenceRoutes(mux, preferenceRouteService{}, middleware.NewOAuthAuthMiddleware(unsealer, nil)) + + routes := walkRoutes(t, mux) + require.Len(t, routes, 2, "notification preferences must register exactly GET getPreferences and POST putPreferences") + for _, key := range []routeKey{ + {method: http.MethodGet, path: "/xrpc/social.coves.notification.getPreferences"}, + {method: http.MethodPost, path: "/xrpc/social.coves.notification.putPreferences"}, + } { + t.Run(key.String(), func(t *testing.T) { + chain, registered := routes[key] + require.True(t, registered, "%s must be registered", key) + facts := chainFacts(chain, unsealer) + require.Equal(t, 1, countKind(facts, mwRequireAuth), "%s needs exactly one RequireAuth", key) + require.Equal(t, 0, countKind(facts, mwOptionalAuth), "%s must not use OptionalAuth", key) + }) + } +} diff --git a/internal/api/routes/notification_routes_test.go b/internal/api/routes/notification_routes_test.go new file mode 100644 index 0000000..0c95a90 --- /dev/null +++ b/internal/api/routes/notification_routes_test.go @@ -0,0 +1,65 @@ +package routes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "Coves/internal/api/middleware" + "Coves/internal/core/notifications" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" +) + +type unreadRouteService struct { + reached bool + updateSeenCalled bool +} + +var _ notifications.Service = (*unreadRouteService)(nil) + +func (s *unreadRouteService) CountUnread(context.Context, string) (int, error) { + s.reached = true + return 3, nil +} + +func (s *unreadRouteService) UpdateSeen(context.Context, string, time.Time) error { + s.updateSeenCalled = true + return nil +} + +func TestNotificationRoutes_AuthenticatedGetOnly(t *testing.T) { + service := &unreadRouteService{} + unsealer := &probeUnsealer{seen: map[string]bool{}} + mux := chi.NewRouter() + RegisterNotificationRoutes(mux, service, middleware.NewOAuthAuthMiddleware(unsealer, nil)) + + routes := walkRoutes(t, mux) + key := routeKey{method: http.MethodGet, path: "/xrpc/social.coves.notification.getUnreadCount"} + chain, registered := routes[key] + require.True(t, registered, "notification routes must serve GET getUnreadCount") + facts := chainFacts(chain, unsealer) + require.Equal(t, 1, countKind(facts, mwRequireAuth), "getUnreadCount needs exactly one RequireAuth") + require.Equal(t, 0, countKind(facts, mwOptionalAuth)) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, key.path, nil)) + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.False(t, service.reached, "anonymous requests must not reach CountUnread") + + require.Len(t, routes, 2, "notification routes must expose GET getUnreadCount and POST updateSeen") + postKey := routeKey{method: http.MethodPost, path: "/xrpc/social.coves.notification.updateSeen"} + postChain, registered := routes[postKey] + require.True(t, registered, "notification routes must serve POST updateSeen") + postFacts := chainFacts(postChain, unsealer) + require.Equal(t, 1, countKind(postFacts, mwRequireAuth), "updateSeen needs exactly one RequireAuth") + require.Equal(t, 0, countKind(postFacts, mwOptionalAuth)) + + postRec := httptest.NewRecorder() + mux.ServeHTTP(postRec, httptest.NewRequest(http.MethodPost, postKey.path, nil)) + require.Equal(t, http.StatusUnauthorized, postRec.Code) + require.False(t, service.updateSeenCalled, "anonymous requests must not reach UpdateSeen") +} diff --git a/internal/atproto/jetstream/authorpost.go b/internal/atproto/jetstream/authorpost.go index 5dd42ac..746e7f5 100644 --- a/internal/atproto/jetstream/authorpost.go +++ b/internal/atproto/jetstream/authorpost.go @@ -549,7 +549,7 @@ func (c *PostEventConsumer) tombstoneAuthorPost(ctx context.Context, authorDID s return err } - applied, err := c.tombstoneRecordIfRevWins(ctx, uri, commit.Rev) + applied, err := c.tombstoneRecordIfRevWins(ctx, uri, commit.Rev, authorDID) if err != nil { return err } @@ -766,12 +766,12 @@ func (c *PostEventConsumer) upsertAuthorPost(ctx context.Context, authorDID stri var applied bool if found { applied, err = c.applyPostContentUpdate(ctx, postContentUpdate{ - uri: uri, storedID: stored.id, rev: commit.Rev, cid: commit.CID, + uri: uri, authorDID: authorDID, storedID: stored.id, rev: commit.Rev, cid: commit.CID, title: record.Title, content: record.Content, facets: facetsJSON, embed: embedJSON, labels: labelsJSON, bridgedUpvotes: up, bridgedDownvotes: down, bridgedAsOf: asOf, storedAsOf: stored.bridgedAsOf, storedDeletedAt: stored.deletedAt, - storedIndexedAt: stored.indexedAt, timeUS: timeUS, authorDID: authorDID, + storedIndexedAt: stored.indexedAt, timeUS: timeUS, }) if err != nil { return err diff --git a/internal/atproto/jetstream/comment_consumer.go b/internal/atproto/jetstream/comment_consumer.go index d46b726..8ba70ff 100644 --- a/internal/atproto/jetstream/comment_consumer.go +++ b/internal/atproto/jetstream/comment_consumer.go @@ -14,6 +14,7 @@ import ( "Coves/internal/core/comments" "Coves/internal/core/embeds" "Coves/internal/core/moderation" + "Coves/internal/core/notifications" "Coves/internal/core/posts" "Coves/internal/core/richtext" @@ -42,6 +43,10 @@ type CommentEventConsumer struct { // nil means default-deny (bridgedStats are ignored for every comment). bridgeTrust *BridgeTrust mediaReconciler MediaReconciler + // notifications writes reply and mention notifications, removes an author-deleted + // comment's notifications and repoints its upvote groups, all inside the event's + // transaction. nil means notifications are neither written nor removed. + notifications notifications.Repository } // CommentEventConsumerOption configures optional CommentEventConsumer behaviour. @@ -125,6 +130,19 @@ func WithCommentBridgeTrust(bt *BridgeTrust) CommentEventConsumerOption { return func(c *CommentEventConsumer) { c.bridgeTrust = bt } } +// WithCommentNotifications makes the consumer write reply and mention notifications for +// the comments it indexes and remove or repoint them when an author deletes or +// re-creates a comment. +func WithCommentNotifications(repository notifications.Repository) CommentEventConsumerOption { + return func(c *CommentEventConsumer) { c.notifications = repository } +} + +// NotificationsWired reports whether the consumer maintains comment notifications. +func (c *CommentEventConsumer) NotificationsWired() bool { return c.notifications != nil } + +// BridgeTrustWired reports whether the consumer holds a bridge trust gate. +func (c *CommentEventConsumer) BridgeTrustWired() bool { return c.bridgeTrust != nil } + // NewCommentEventConsumer creates a new Jetstream consumer for comment events func NewCommentEventConsumer( commentRepo comments.Repository, @@ -266,7 +284,8 @@ func (c *CommentEventConsumer) createComment(ctx context.Context, repoDID string } // Atomically: Rev-gate + Index comment + Update parent counts - if err := c.indexCommentAndUpdateCounts(ctx, comment, commit.Rev); err != nil { + createEventTime, _ := eventTime(timeUS) + if err := c.indexCommentAndUpdateCounts(ctx, comment, commit.Rev, createEventTime); err != nil { return fmt.Errorf("failed to index comment and update counts: %w", err) } @@ -276,11 +295,10 @@ func (c *CommentEventConsumer) createComment(ctx context.Context, repoDID string // updateComment updates an existing comment's content fields. // -// Like updatePost, this is idempotent and error-return means log-and-drop (the -// connector tracks no cursor and live-tails Jetstream, so a returned error is NOT -// replayed): the folded bridged counts only self-heal on the bridge's next record -// edit. We therefore skip benign no-ops (missing row, soft-deleted row) cleanly and -// reserve errors for transient infra faults. +// Like updatePost, this is idempotent. The connector retries a returned error +// in-line, then dead-letters the event for the DeadLetterRedriver to replay, so +// benign no-ops (missing row, soft-deleted row, superseded event) return nil +// rather than an error that would be retried and dead-lettered for nothing. func (c *CommentEventConsumer) updateComment(ctx context.Context, repoDID string, commit *CommitEvent, timeUS int64) error { if commit.Record == nil { return fmt.Errorf("%w: comment update event missing record data", ErrPermanentEvent) @@ -448,7 +466,7 @@ func (c *CommentEventConsumer) updateComment(ctx context.Context, repoDID string // each feed stamps its own emission time — a pre-edit update replayed by the // lagging bsky feed carries a NEWER time_us than the edit it would regress. // Only rev, assigned by the repo itself, orders events across feeds. - tx, err := c.db.BeginTx(ctx, nil) + tx, err := c.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } @@ -467,6 +485,29 @@ func (c *CommentEventConsumer) updateComment(ctx context.Context, repoDID string return nil } + var erased bool + var storedFacets, lockedParentURI, lockedRootURI string + var storedCreatedAt time.Time + if c.notifications != nil { + // Acquire the erasure lock before locking any content row, so Delete + // cannot wait for a row held by an edit waiting for the erasure lock. + erased, err = c.notifications.ErasureGateTx(ctx, tx, repoDID) + if err != nil { + return fmt.Errorf("check comment actor erasure before updating: %w", err) + } + err = tx.QueryRowContext(ctx, + `SELECT COALESCE(content_facets::text, ''), created_at, parent_uri, root_uri + FROM comments WHERE uri = $1 AND deleted_at IS NULL FOR UPDATE`, uri, + ).Scan(&storedFacets, &storedCreatedAt, &lockedParentURI, &lockedRootURI) + if errors.Is(err, sql.ErrNoRows) { + log.Printf("Update event for comment that was deleted between load and write: %s (skipping)", uri) + return nil + } + if err != nil { + return fmt.Errorf("failed to load stored comment for edit notifications: %w", err) + } + } + result, err := tx.ExecContext(ctx, updateQuery, uri, commit.CID, commentRecord.Content, facetsJSON, embedJSON, labelsJSON, pq.Array(commentRecord.Langs), @@ -492,6 +533,17 @@ func (c *CommentEventConsumer) updateComment(ctx context.Context, repoDID string return nil } + if c.notifications != nil && !erased { + editEventTime, _ := eventTime(timeUS) + if err := c.writeCommentEditNotifications(ctx, tx, notifications.CommentRecord{ + URI: uri, CID: commit.CID, AuthorDID: repoDID, + ParentURI: lockedParentURI, RootURI: lockedRootURI, CreatedAt: storedCreatedAt, + EditEventTime: editEventTime, + }, facetsJSON, storedFacets); err != nil { + return err + } + } + if err := c.commitCommentWrite(ctx, tx, uri); err != nil { return fmt.Errorf("failed to commit comment update transaction: %w", err) } @@ -550,11 +602,14 @@ func (c *CommentEventConsumer) blockRejectedUpdateMedia(ctx context.Context, uri // equal-or-older rev then loses the gate). The gate row is advanced — and // committed — even when the comment was never indexed, so the create's late // copy is rejected too. +// +// With notifications wired, the shared erasure lock is taken before the comment +// row. The comment's existing notifications remain after author deletion. func (c *CommentEventConsumer) deleteComment(ctx context.Context, repoDID string, commit *CommitEvent) error { // Build AT-URI for the comment being deleted uri := fmt.Sprintf("at://%s/social.coves.community.comment/%s", repoDID, commit.RKey) - tx, err := c.db.BeginTx(ctx, nil) + tx, err := c.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } @@ -576,6 +631,14 @@ func (c *CommentEventConsumer) deleteComment(ctx context.Context, repoDID string return nil } + if c.notifications != nil { + // Take the erasure lock before touching the comment row, so a delete + // cannot wait on erasure while holding a row needed by account deletion. + if _, err := c.notifications.ErasureGateTx(ctx, tx, repoDID); err != nil { + return fmt.Errorf("check comment actor erasure before deleting: %w", err) + } + } + // 1. Soft-delete the comment: blank content but preserve structure. // DELETE event from Jetstream = author deleted their own comment (the repo // owner IS the commenter), so deleted_by is the repo DID. @@ -615,9 +678,10 @@ func (c *CommentEventConsumer) deleteComment(ctx context.Context, repoDID string return nil } -// indexCommentAndUpdateCounts atomically indexes a comment and updates parent counts -func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, comment *comments.Comment, rev string) error { - tx, err := c.db.BeginTx(ctx, nil) +// indexCommentAndUpdateCounts atomically indexes a comment and updates parent counts. +// editEventTime is the create event's Jetstream time, used when an active re-create is an edit. +func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, comment *comments.Comment, rev string, editEventTime time.Time) error { + tx, err := c.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } @@ -643,6 +707,17 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, return nil } + var erased bool + if c.notifications != nil { + // Lock before touching comments or posts: waiting for the shared erasure + // lock while holding a content row could deadlock with Delete, which holds + // the exclusive erasure lock while waiting for that row. + erased, err = c.notifications.ErasureGateTx(ctx, tx, comment.CommenterDID) + if err != nil { + return fmt.Errorf("check comment actor erasure before indexing: %w", err) + } + } + // 1. Check if comment exists and handle resurrection case // In atProto, deleted records' rkeys become available - users can recreate with same rkey // We must distinguish: idempotent replay (skip) vs resurrection (update + restore counts) @@ -655,6 +730,7 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, var commentID int64 var isResurrectionWithSameParent bool // Track if we should skip parent count increment + var repairKeptNotifications bool // Different-parent resurrection: kept rows need repair if checkErr == nil { // Comment exists @@ -674,6 +750,15 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, existingParentURI == comment.ParentURI && existingRootURI == comment.RootURI { log.Printf("Re-create of active comment with newer rev: %s (applying new content, CID %s -> %s)", comment.URI, existingCID, comment.CID) + var storedFacets, lockedParentURI, lockedRootURI string + var storedCreatedAt time.Time + if c.notifications != nil { + err := tx.QueryRowContext(ctx, `SELECT COALESCE(content_facets::text, ''), created_at, parent_uri, root_uri + FROM comments WHERE id = $1 FOR UPDATE`, existingID).Scan(&storedFacets, &storedCreatedAt, &lockedParentURI, &lockedRootURI) + if err != nil { + return fmt.Errorf("read stored comment before re-create: %w", err) + } + } recreateQuery := ` UPDATE comments SET @@ -705,6 +790,17 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, ); err != nil { return fmt.Errorf("failed to apply re-created comment content: %w", err) } + // An active re-create is an edit: use the pre-update creation time for + // activation, and let edit fan-out suppress the unchanged reply recipient. + if c.notifications != nil && !erased { + if err := c.writeCommentEditNotifications(ctx, tx, notifications.CommentRecord{ + URI: comment.URI, CID: comment.CID, AuthorDID: comment.CommenterDID, + ParentURI: lockedParentURI, RootURI: lockedRootURI, CreatedAt: storedCreatedAt, + EditEventTime: editEventTime, + }, comment.ContentFacets, storedFacets); err != nil { + return err + } + } // Parent unchanged and the row was never decounted, so parent counts // are already correct — commit without the increment sections below. if commitErr := c.commitCommentWrite(ctx, tx, comment.URI); commitErr != nil { @@ -803,6 +899,16 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, if err != nil { return fmt.Errorf("failed to resurrect comment: %w", err) } + // Author delete leaves groups (which have no record_uri) and native votes + // intact. Group roots are written on insert only, so repoint them here. + // Not gated on erased: erasure deleted every row whose recipient is the + // author, so an erased author has no group for this to match. + if c.notifications != nil && existingRootURI != comment.RootURI { + if err := c.notifications.ReplaceUpvoteGroupRootTx(ctx, tx, comment.CommenterDID, comment.URI, comment.RootURI); err != nil { + return fmt.Errorf("replace comment upvote group root: %w", err) + } + } + repairKeptNotifications = !isResurrectionWithSameParent } else if errors.Is(checkErr, sql.ErrNoRows) { // Comment doesn't exist - insert new comment @@ -886,6 +992,11 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, // Since deleteComment() no longer decrements counts (deleted comments shown as "[deleted]" placeholders), // resurrecting a comment with the same parent should NOT increment the count again. // However, if the parent CHANGED (user recreated comment on different post/thread), we DO increment. + // Author deletion keeps notifications. A resurrection under the same parent + // retains those rows and fans out only to new recipients within the remaining + // mention budget. A different-parent resurrection repairs the kept rows around + // create fan-out (writeCreateNotificationsAfterCounts) so replies and roots + // match the new threading. // // NOTE: Post comment_count reconciliation IS implemented in PostEventConsumer.createPostAndUpdateCounts() // When a comment arrives before its parent post, the post update below returns 0 rows @@ -896,6 +1007,16 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, // Test coverage: TestPostConsumer_CommentCountReconciliation in post_consumer_test.go if isResurrectionWithSameParent { log.Printf("Resurrection with same parent - skipping parent count increment for: %s", comment.URI) + if c.notifications != nil && !erased { + if err := c.writeCommentCreateNotifications(ctx, tx, comment); err != nil { + return err + } + } + if c.notifications != nil { + if err := c.notifications.DeleteReplyRecipientMentionsTx(ctx, tx, comment.URI); err != nil { + return fmt.Errorf("repair resurrected comment notifications: %w", err) + } + } if err := c.commitCommentWrite(ctx, tx, comment.URI); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } @@ -978,12 +1099,19 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, // Unknown or unsupported parent collection // Comment is still indexed, we just don't update parent counts log.Printf("Comment parent has unsupported collection: %s (comment indexed, parent count not updated)", collection) + if err := c.writeCreateNotificationsAfterCounts(ctx, tx, comment, erased, repairKeptNotifications); err != nil { + return err + } if commitErr := c.commitCommentWrite(ctx, tx, comment.URI); commitErr != nil { return fmt.Errorf("failed to commit transaction: %w", commitErr) } return nil } + if err := c.writeCreateNotificationsAfterCounts(ctx, tx, comment, erased, repairKeptNotifications); err != nil { + return err + } + // Commit transaction if err := c.commitCommentWrite(ctx, tx, comment.URI); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) @@ -992,6 +1120,81 @@ func (c *CommentEventConsumer) indexCommentAndUpdateCounts(ctx context.Context, return nil } +// writeCommentEditNotifications writes the mentions an edit adds in the edit's +// transaction. record carries the edit's URI, CID, author and Jetstream event time, +// with the threading and creation time read under lock; facets are the edit's new facets. +func (c *CommentEventConsumer) writeCommentEditNotifications(ctx context.Context, tx *sql.Tx, record notifications.CommentRecord, facets *string, storedFacets string) error { + if facets != nil { + record.FacetsJSON = *facets + } + intents, err := notifications.FanoutCommentEdit(ctx, c.notifications.LookupsTx(tx), c.bridgeTrust, record, storedFacets) + if err != nil { + return fmt.Errorf("compute comment edit notifications: %w", err) + } + if err := c.notifications.ApplyTx(ctx, tx, intents); err != nil { + return fmt.Errorf("write comment edit notifications: %w", err) + } + return nil +} + +// writeCreateNotificationsAfterCounts runs create fan-out for a new comment or a +// different-parent resurrection, after the parent and root count updates. Those +// updates lock the new reply recipient's content row before any notification row +// is touched, the order account erasure also uses, so the repair cannot deadlock +// with erasure of that recipient. +// +// With repairKeptRows, the kept rows are repaired before fan-out, whether or not +// the actor is erased. A mention is removed only after fan-out, and only +// when the same recipient now holds a reply row for the record, so a recipient +// whose reply was gated out keeps its mention. +func (c *CommentEventConsumer) writeCreateNotificationsAfterCounts(ctx context.Context, tx *sql.Tx, comment *comments.Comment, erased, repairKeptRows bool) error { + if c.notifications == nil { + return nil + } + if repairKeptRows { + subjectURI, err := notifications.CommentReplySubject(ctx, c.notifications.LookupsTx(tx), notifications.CommentRecord{ + ParentURI: comment.ParentURI, RootURI: comment.RootURI, + }) + if err != nil { + return fmt.Errorf("resolve resurrected comment reply subject: %w", err) + } + if err := c.notifications.RepairResurrectedCommentNotificationsTx(ctx, tx, comment.URI, subjectURI, comment.RootURI); err != nil { + return fmt.Errorf("repair resurrected comment notifications: %w", err) + } + } + if !erased { + if err := c.writeCommentCreateNotifications(ctx, tx, comment); err != nil { + return err + } + } + if repairKeptRows { + if err := c.notifications.DeleteReplyRecipientMentionsTx(ctx, tx, comment.URI); err != nil { + return fmt.Errorf("repair resurrected comment notifications: %w", err) + } + } + return nil +} + +// writeCommentCreateNotifications writes eligible reply and mention intents in the index transaction. +func (c *CommentEventConsumer) writeCommentCreateNotifications(ctx context.Context, tx *sql.Tx, comment *comments.Comment) error { + var facetsJSON string + if comment.ContentFacets != nil { + facetsJSON = *comment.ContentFacets + } + intents, err := notifications.FanoutCommentCreate(ctx, c.notifications.LookupsTx(tx), c.bridgeTrust, notifications.CommentRecord{ + URI: comment.URI, CID: comment.CID, AuthorDID: comment.CommenterDID, + ParentURI: comment.ParentURI, RootURI: comment.RootURI, CreatedAt: comment.CreatedAt, + FacetsJSON: facetsJSON, + }) + if err != nil { + return fmt.Errorf("compute comment notifications: %w", err) + } + if err := c.notifications.ApplyTx(ctx, tx, intents); err != nil { + return fmt.Errorf("write comment notifications: %w", err) + } + return nil +} + // validateCommentEvent performs security validation on comment events func (c *CommentEventConsumer) validateCommentEvent(ctx context.Context, repoDID string, comment *CommentRecordFromJetstream) error { // SECURITY: Comments MUST come from user repositories (repo owner = commenter DID) diff --git a/internal/atproto/jetstream/comment_notification_active_recreate_test.go b/internal/atproto/jetstream/comment_notification_active_recreate_test.go new file mode 100644 index 0000000..d74e639 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_active_recreate_test.go @@ -0,0 +1,201 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +const activeRecreateCID = "bafyreiactiverecreatenew" + +func activeRecreateEvent(t *testing.T, fixture mentionEditFixture, createdAt string, recipients ...mentionEditRecipient) (*JetstreamEvent, string) { + t.Helper() + revision := testkit.TID() + require.Less(t, fixture.revision, revision, "active re-create must have a newer rev") + record := fixture.record(t, createdAt, recipients...) + content := record["content"].(string) + " re-created" + record["content"] = content + return revCommitEvent(fixture.gate.commenterDID, CommentCollection, "create", fixture.key, + revision, activeRecreateCID, time.Now().Add(time.Second).UnixMicro(), record), content +} + +func requireActiveRecreateApplied(t *testing.T, fixture mentionEditFixture, content string) { + t.Helper() + var cid, storedContent string + var deletedAt sql.NullTime + require.NoError(t, fixture.gate.db.QueryRow(`SELECT cid, content, deleted_at FROM comments WHERE uri = $1`, fixture.uri). + Scan(&cid, &storedContent, &deletedAt)) + require.False(t, deletedAt.Valid, "re-created comment must remain active") + require.Equal(t, activeRecreateCID, cid, "the newer create must replace the active row's CID") + require.Equal(t, content, storedContent, "the newer create must replace the active row's content") +} + +func TestCommentConsumer_ActiveRecreateNotifiesOnlyNewMentionAndKeepsExistingRows(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + postAuthor := fixture.postAuthor(t) + kept := fixture.recipient(t) + added := fixture.recipient(t) + fixture.create(t, kept) + require.Equal(t, 1, mentionEditRows(t, fixture, kept, "mention")) + require.Equal(t, 1, mentionEditRows(t, fixture, postAuthor, "postReply")) + previousRows := countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, fixture.uri) + require.Equal(t, 2, previousRows) + event, content := activeRecreateEvent(t, fixture, fixture.createdAt, kept, added) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), event)) + requireActiveRecreateApplied(t, fixture, content) + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, + fixture.uri, added.did, activeRecreateCID), "newly mentioned E gets one mention with the re-created CID") + require.Equal(t, 1, mentionEditRows(t, fixture, kept, "mention"), "retained D must not receive a duplicate") + require.Equal(t, 1, mentionEditRows(t, fixture, postAuthor, "postReply"), "the existing reply stays") + require.Equal(t, previousRows+1, countRows(t, fixture.gate.db, + `SELECT count(*) FROM notifications WHERE record_uri = $1`, fixture.uri)) +} + +func TestCommentConsumer_ActiveRecreateDiffUsesStoredFacetsEvenWhenOriginalRecipientWasUnindexed(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + id := testkit.UniqueID(t) + kept := mentionEditRecipient{did: "did:plc:" + id + "mentioned", handle: id + "mentioned.test"} + added := fixture.recipient(t) + fixture.create(t, kept) + requireStoredMentionFacets(t, fixture.gate.db, fixture.uri, kept.did) + require.Zero(t, mentionEditRows(t, fixture, kept, "mention"), "unindexed D receives no initial notification") + _, err := fixture.gate.db.Exec(`INSERT INTO users (did, handle, pds_url, created_at) + VALUES ($1, $2, $3, NOW())`, kept.did, kept.handle, bridgedTestNativePDS) + require.NoError(t, err, "D becomes eligible only after the original comment") + event, content := activeRecreateEvent(t, fixture, fixture.createdAt, kept, added) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), event)) + requireActiveRecreateApplied(t, fixture, content) + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, + fixture.uri, added.did, activeRecreateCID), "only newly mentioned E should be notified") + require.Zero(t, mentionEditRows(t, fixture, kept, "mention"), + "D was in the stored facets despite being unindexed at create; no backfill on re-create") +} + +func TestCommentConsumer_ActiveRecreateDoesNotMentionReplyRecipient(t *testing.T) { + t.Parallel() + for _, kind := range []string{"post reply", "comment reply"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + replyRecipient := fixture.postAuthor(t) + reason := "postReply" + if kind == "comment reply" { + replyRecipient = fixture.recipient(t) + reason = "commentReply" + parentKey := testkit.TID() + fixture.parentURI = "at://" + replyRecipient.did + "/" + CommentCollection + "/" + parentKey + fixture.parentCID = "bafyreiactiverecreateparent" + parentRecord := revCommentRecord("C comments on B's post", fixture.post.uri, + fixture.post.cid, fixture.post.uri, fixture.post.cid) + parentRecord["createdAt"] = fixture.createdAt + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + replyRecipient.did, CommentCollection, "create", parentKey, testkit.TID(), + fixture.parentCID, time.Now().UnixMicro(), parentRecord))) + } + added := fixture.recipient(t) + fixture.create(t) + require.Equal(t, 1, mentionEditRows(t, fixture, replyRecipient, reason), + "reply recipient must have the original reply notification") + require.Zero(t, mentionEditRows(t, fixture, replyRecipient, "mention")) + event, content := activeRecreateEvent(t, fixture, fixture.createdAt, replyRecipient, added) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), event)) + requireActiveRecreateApplied(t, fixture, content) + require.Equal(t, 1, mentionEditRows(t, fixture, replyRecipient, reason)) + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2`, fixture.uri, replyRecipient.did), + "resolved reply recipient must retain only the reply row") + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, + fixture.uri, added.did, activeRecreateCID), "E receives exactly one new mention") + }) + } +} + +func TestCommentConsumer_ActiveRecreateActivationUsesStoredCreatedAt(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + storedCreatedAt, err := time.Parse(time.RFC3339Nano, fixture.createdAt) + require.NoError(t, err) + activation := storedCreatedAt.Add(30 * time.Second) + incomingCreatedAt := storedCreatedAt.Add(time.Minute) + require.True(t, storedCreatedAt.Before(activation) && activation.Before(incomingCreatedAt)) + _, err = fixture.gate.db.Exec(`UPDATE notification_activation SET activated_at = $1`, activation) + require.NoError(t, err) + fixture.create(t) + require.Zero(t, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, fixture.uri), + "stored pre-activation comment must not generate a reply") + event, content := activeRecreateEvent(t, fixture, incomingCreatedAt.UTC().Format(time.RFC3339Nano), added) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), event)) + requireActiveRecreateApplied(t, fixture, content) + require.Zero(t, mentionEditRows(t, fixture, added, "mention"), + "incoming createdAt cannot activate a comment whose stored created_at predates activation") +} + +func TestCommentConsumer_ActiveRecreateNotificationFailureRollsBackContentFacetsAndRevision(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + var beforeCID, beforeContent, beforeRevision string + var beforeFacets sql.NullString + require.NoError(t, fixture.gate.db.QueryRow(`SELECT cid, content, content_facets FROM comments WHERE uri = $1`, + fixture.uri).Scan(&beforeCID, &beforeContent, &beforeFacets)) + require.NoError(t, fixture.gate.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, + fixture.uri).Scan(&beforeRevision)) + require.Equal(t, fixture.revision, beforeRevision) + injectedError := errors.New("injected active re-create notification failure") + failingRepository := &failingCommentNotificationRepository{ + delegate: postgres.NewNotificationRepository(fixture.gate.db), failure: injectedError, + } + fixture.consumer = fixture.gate.commentConsumer(WithCommentNotifications(failingRepository)) + event, _ := activeRecreateEvent(t, fixture, fixture.createdAt, added) + err := fixture.consumer.HandleEvent(context.Background(), event) + require.ErrorIs(t, err, injectedError, "notification failure must propagate from active re-create") + require.Len(t, failingRepository.intents, 1, "new mention must reach ApplyTx") + require.Equal(t, notifications.ReasonMention, failingRepository.intents[0].Reason) + require.Equal(t, added.did, failingRepository.intents[0].RecipientDID) + var afterCID, afterContent, afterRevision string + var afterFacets sql.NullString + require.NoError(t, fixture.gate.db.QueryRow(`SELECT cid, content, content_facets FROM comments WHERE uri = $1`, + fixture.uri).Scan(&afterCID, &afterContent, &afterFacets)) + require.NoError(t, fixture.gate.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, + fixture.uri).Scan(&afterRevision)) + require.Equal(t, beforeCID, afterCID, "failed re-create must retain the original CID") + require.Equal(t, beforeContent, afterContent, "failed re-create must retain the original content") + require.Equal(t, beforeFacets, afterFacets, "failed re-create must retain the original facets") + require.Equal(t, beforeRevision, afterRevision, "failed re-create must roll back the rev gate") + require.Zero(t, mentionEditRows(t, fixture, added, "mention")) +} + +// The consumer still indexes an erased actor's events, so an active re-create +// can arrive after A is erased while A's comment row is still present. +func TestCommentConsumer_ActiveRecreateByErasedActorDoesNotNotifyAddedMention(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + _, err := fixture.gate.db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, fixture.gate.commenterDID) + require.NoError(t, err, "fixture: retain the active comment row and mark A erased") + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM comments WHERE uri = $1 AND deleted_at IS NULL`, fixture.uri)) + event, content := activeRecreateEvent(t, fixture, fixture.createdAt, added) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), event)) + requireActiveRecreateApplied(t, fixture, content) + require.Zero(t, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2`, fixture.uri, added.did), + "erased A's active re-create must not notify newly mentioned E") +} diff --git a/internal/atproto/jetstream/comment_notification_author_delete_erasure_test.go b/internal/atproto/jetstream/comment_notification_author_delete_erasure_test.go new file mode 100644 index 0000000..0bdb6e4 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_author_delete_erasure_test.go @@ -0,0 +1,119 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func TestCommentConsumer_AuthorDeleteErasureFirstWaitsOnAdvisoryLockBeforeCommentRow(t *testing.T) { + t.Parallel() + fixture := newCommentAuthorDeleteFixture(t) + key := testkit.TID() + createRevision := testkit.TID() + commentURI := fixture.createReply(t, key, createRevision, "bafyreiauthordeleteerasure", + fixture.replyRecord("A replies to B before erasure", fixture.postURI, fixture.postCID)) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'postReply'`, commentURI, revTestAuthor)) + deleteRevision := testkit.TID() + require.Less(t, createRevision, deleteRevision) + deleteEvent := revCommitEvent(revTestCommenter, CommentCollection, "delete", key, deleteRevision, + "", time.Now().UnixMicro(), nil) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + results := make(chan error, 1) + started, finished := false, false + // Register before the open transaction: its cleanup rolls back the fixture + // lock before this cleanup waits for the blocked consumer to finish. + t.Cleanup(func() { + if started && !finished { + commentErasureResult(t, ctx, results, "HandleEvent(delete) after fixture rollback") + } + }) + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, fixture.db, revTestCommenter) + _, err := transaction.ExecContext(ctx, "SELECT pg_advisory_xact_lock("+postgres.ErasureLockKeySQL+")", revTestCommenter) + require.NoError(t, err) + _, err = transaction.ExecContext(ctx, `INSERT INTO deleted_accounts (did, deleted_at) VALUES ($1, NOW())`, revTestCommenter) + require.NoError(t, err) + commentDelete, err := transaction.ExecContext(ctx, `DELETE FROM comments WHERE commenter_did = $1`, revTestCommenter) + require.NoError(t, err) + commentRows, err := commentDelete.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, commentRows, "fixture erasure must hold Y's comment row") + _, err = transaction.ExecContext(ctx, `DELETE FROM notifications WHERE actor_did = $1`, revTestCommenter) + require.NoError(t, err) + + started = true + go func() { results <- fixture.consumer.HandleEvent(ctx, deleteEvent) }() + consumerProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "pg_advisory_xact_lock_shared") + var waitingOnlyForErasure bool + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE waiter.pid = $1 AND holder.pid = $2 + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND NOT EXISTS (SELECT 1 FROM pg_locks other WHERE other.pid = $1 + AND NOT other.granted AND other.locktype IN ('tuple', 'transactionid')) + AND NOT EXISTS (SELECT 1 FROM pg_locks content WHERE content.pid = $1 + AND content.relation = 'comments'::regclass) + )`, consumerProcessID, fixtureProcessID).Scan(&waitingOnlyForErasure)) + require.True(t, waitingOnlyForErasure, + "author delete must wait for erasure's exclusive advisory lock before holding or waiting on the comments row") + + require.NoError(t, transaction.Commit(), "finish fixture account erasure") + commentErasureResult(t, ctx, results, "HandleEvent(delete) after account erasure") + finished = true + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "erasure removed Y before the author delete resumed") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "erasure and the author delete must leave no notifications for Y") +} + +func TestCommentConsumer_AuthorDeleteErasureGateRequestsReadCommittedExplicitly(t *testing.T) { + t.Parallel() + fixture := newCommentAuthorDeleteFixture(t) + key := testkit.TID() + createRevision := testkit.TID() + commentURI := fixture.createReply(t, key, createRevision, "bafyreiauthordeleteisolation", + fixture.replyRecord("A replies to B before deletion", fixture.postURI, fixture.postCID)) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI)) + + ctx := context.Background() + var database string + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT current_database()`).Scan(&database)) + _, err := fixture.db.ExecContext(ctx, + "ALTER DATABASE "+pq.QuoteIdentifier(database)+" SET default_transaction_isolation = 'repeatable read'") + require.NoError(t, err) + fixture.db.SetMaxIdleConns(0) + control, err := fixture.db.BeginTx(ctx, nil) + require.NoError(t, err) + var defaultIsolation string + require.NoError(t, control.QueryRowContext(ctx, `SELECT current_setting('transaction_isolation')`).Scan(&defaultIsolation)) + require.NoError(t, control.Rollback()) + require.Equal(t, "repeatable read", defaultIsolation, + "control: a transaction without explicit options must inherit the database default") + + deleteRevision := testkit.TID() + require.Less(t, createRevision, deleteRevision) + require.NoError(t, fixture.consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "delete", key, deleteRevision, "", time.Now().UnixMicro(), nil, + )), "an applied author delete must request read committed for its erasure gate") + var deletedAt sql.NullTime + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT deleted_at FROM comments WHERE uri = $1`, commentURI).Scan(&deletedAt)) + require.True(t, deletedAt.Valid, "author delete must soft-delete the comment") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "author delete must keep Y's notification under READ COMMITTED") +} diff --git a/internal/atproto/jetstream/comment_notification_author_delete_test.go b/internal/atproto/jetstream/comment_notification_author_delete_test.go new file mode 100644 index 0000000..14b6617 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_author_delete_test.go @@ -0,0 +1,273 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type commentAuthorDeleteFixture struct { + db *sql.DB + consumer *CommentEventConsumer + postURI string + postCID string + createdAt string +} + +func newCommentAuthorDeleteFixture(t *testing.T) commentAuthorDeleteFixture { + t.Helper() + db := testkit.DB(t) + _, postURI, postCID := setupRevFixtures(t, db) + return commentAuthorDeleteFixture{ + db: db, consumer: NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db))), + postURI: postURI, postCID: postCID, + createdAt: activatedCommentNotificationTime(t, db, context.Background()), + } +} + +func (fixture commentAuthorDeleteFixture) parent(t *testing.T, authorDID string) (string, string) { + t.Helper() + key := testkit.TID() + uri := "at://" + authorDID + "/" + CommentCollection + "/" + key + cid := "bafyreiauthordeleteparent" + record := revCommentRecord("An indexed parent comment", fixture.postURI, fixture.postCID, fixture.postURI, fixture.postCID) + record["createdAt"] = fixture.createdAt + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + authorDID, CommentCollection, "create", key, testkit.TID(), cid, time.Now().UnixMicro(), record, + ))) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, uri), + "fixture: parent comment must be indexed") + return uri, cid +} + +func (fixture commentAuthorDeleteFixture) replyRecord(content, parentURI, parentCID string) map[string]interface{} { + record := revCommentRecord(content, fixture.postURI, fixture.postCID, parentURI, parentCID) + record["createdAt"] = fixture.createdAt + return record +} + +func (fixture commentAuthorDeleteFixture) createReply(t *testing.T, key, revision, cid string, record map[string]interface{}) string { + t.Helper() + uri := "at://" + revTestCommenter + "/" + CommentCollection + "/" + key + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "create", key, revision, cid, time.Now().UnixMicro(), record, + ))) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, uri), + "fixture: reply must be indexed") + return uri +} + +func (fixture commentAuthorDeleteFixture) mentionRecipient(t *testing.T) (string, string) { + t.Helper() + id := testkit.UniqueID(t) + did, handle := "did:plc:"+id, id+".test" + insertBridgedUserOnPDS(t, fixture.db, did, handle, bridgedTestNativePDS) + return did, handle +} + +func (fixture commentAuthorDeleteFixture) mentionedReply(t *testing.T, parentURI, parentCID, mentionedDID, mentionedHandle string) map[string]interface{} { + t.Helper() + content := "A replies and mentions @" + mentionedHandle + record := fixture.replyRecord(content, parentURI, parentCID) + record["facets"] = []interface{}{commentMentionFacet(t, content, mentionedHandle, mentionedDID)} + return record +} + +func TestCommentConsumer_AuthorDeleteKeepsRecordNotificationsAndOtherRowsAndUpvoteGroup(t *testing.T) { + t.Parallel() + fixture := newCommentAuthorDeleteFixture(t) + parentURI, parentCID := fixture.parent(t, revTestAuthor) + mentionedDID, mentionedHandle := fixture.mentionRecipient(t) + replierDID, _ := fixture.mentionRecipient(t) + key := testkit.TID() + createRevision := testkit.TID() + commentURI := fixture.createReply(t, key, createRevision, "bafyreiauthordeleteoriginal", + fixture.mentionedReply(t, parentURI, parentCID, mentionedDID, mentionedHandle)) + replyKey := testkit.TID() + replyURI := "at://" + replierDID + "/" + CommentCollection + "/" + replyKey + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + replierDID, CommentCollection, "create", replyKey, testkit.TID(), "bafyreiauthordeletechild", + time.Now().UnixMicro(), fixture.replyRecord("E replies to Y", commentURI, "bafyreiauthordeleteoriginal"), + ))) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND subject_uri = $2 AND recipient_did = $3 AND reason = 'commentReply'`, + replyURI, commentURI, revTestCommenter)) + + // Directly seed the group: this test concerns comment deletion, not vote indexing. + _, err := fixture.db.ExecContext(context.Background(), `INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri) + VALUES ($1, 'upvote', $2, $3)`, revTestCommenter, commentURI, fixture.postURI) + require.NoError(t, err) + before := notificationRowsForRecordOrSubject(t, fixture.db, commentURI) + require.Len(t, before, 4, "fixture: Y's reply, Y's mention, E's reply to Y and Y's upvote group") + + deleteRevision := testkit.TID() + require.Less(t, createRevision, deleteRevision) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "delete", key, deleteRevision, "", time.Now().UnixMicro(), nil, + ))) + var deletedAt sql.NullTime + require.NoError(t, fixture.db.QueryRow(`SELECT deleted_at FROM comments WHERE uri = $1`, commentURI).Scan(&deletedAt)) + require.True(t, deletedAt.Valid, "author delete must soft-delete Y") + require.Equal(t, before, notificationRowsForRecordOrSubject(t, fixture.db, commentURI), + "author delete must preserve all four rows, including their IDs, CIDs, roots and sort times") +} + +func TestCommentConsumer_AuthorDeleteMissingCommentKeepsNotificationsAndAdvancesTombstone(t *testing.T) { + t.Parallel() + fixture := newCommentAuthorDeleteFixture(t) + key := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + key + mentionedDID, _ := fixture.mentionRecipient(t) + _, err := fixture.db.ExecContext(context.Background(), `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at) + VALUES ($1, 'postReply', $2, $3, $4, $5, $5, NOW()), + ($6, 'mention', $2, $3, $4, NULL, $5, NOW())`, + revTestAuthor, commentURI, "bafyreiauthordeleteunindexed", revTestCommenter, fixture.postURI, mentionedDID) + require.NoError(t, err) + require.Equal(t, 2, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI)) + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI)) + deleteRevision := testkit.TID() + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "delete", key, deleteRevision, "", time.Now().UnixMicro(), nil, + ))) + require.Equal(t, 2, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "zero-row delete must keep notifications for a comment it never indexed") + var storedRevision string + require.NoError(t, fixture.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, commentURI).Scan(&storedRevision)) + require.Equal(t, deleteRevision, storedRevision, "zero-row delete must commit its tombstone revision") +} + +func TestCommentConsumer_StaleAuthorDeleteKeepsCommentNotificationsAndRevision(t *testing.T) { + t.Parallel() + fixture := newCommentAuthorDeleteFixture(t) + parentURI, parentCID := fixture.parent(t, revTestAuthor) + mentionedDID, mentionedHandle := fixture.mentionRecipient(t) + key := testkit.TID() + createRevision := testkit.TID() + commentURI := fixture.createReply(t, key, createRevision, "bafyreiauthordeletestale", + fixture.mentionedReply(t, parentURI, parentCID, mentionedDID, mentionedHandle)) + require.Less(t, revA, createRevision, "stale delete must lose the revision gate") + require.Equal(t, 2, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI)) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "delete", key, revA, "", time.Now().UnixMicro(), nil, + ))) + require.Equal(t, 2, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "stale delete must keep both notification rows") + var content, storedRevision string + var deletedAt sql.NullTime + require.NoError(t, fixture.db.QueryRow(`SELECT content, deleted_at FROM comments WHERE uri = $1`, commentURI). + Scan(&content, &deletedAt)) + require.Equal(t, "A replies and mentions @"+mentionedHandle, content) + require.False(t, deletedAt.Valid, "stale delete must leave Y active") + require.NoError(t, fixture.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, commentURI).Scan(&storedRevision)) + require.Equal(t, createRevision, storedRevision, "stale delete must not advance the revision") +} + +func TestCommentConsumer_ChangedParentRecreateNotifiesNewParentAndEditMentionsOldParent(t *testing.T) { + t.Parallel() + fixture := newCommentAuthorDeleteFixture(t) + oldParentURI, oldParentCID := fixture.parent(t, revTestAuthor) + newParentDID, newParentHandle := fixture.mentionRecipient(t) + newParentURI, newParentCID := fixture.parent(t, newParentDID) + key := testkit.TID() + createRevision := testkit.TID() + commentURI := fixture.createReply(t, key, createRevision, "bafyreiauthordeletefirstparent", + fixture.replyRecord("A replies to B's comment", oldParentURI, oldParentCID)) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'commentReply'`, commentURI, revTestAuthor)) + deleteRevision := testkit.TID() + recreateRevision := testkit.TID() + editRevision := testkit.TID() + require.Less(t, createRevision, deleteRevision) + require.Less(t, deleteRevision, recreateRevision) + require.Less(t, recreateRevision, editRevision) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "delete", key, deleteRevision, "", time.Now().UnixMicro(), nil, + ))) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "create", key, recreateRevision, "bafyreiauthordeletechangedparent", + time.Now().UnixMicro(), fixture.replyRecord("A now replies to C's comment", newParentURI, newParentCID), + ))) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'commentReply' AND subject_uri = $3 + AND record_cid = $4`, commentURI, newParentDID, newParentURI, "bafyreiauthordeletechangedparent"), + "C must have one reply notification for the re-created Y") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2`, commentURI, revTestAuthor), + "B must have no notification for Y after its deletion and re-creation under C") + + var oldParentHandle string + require.NoError(t, fixture.db.QueryRow(`SELECT handle FROM users WHERE did = $1`, revTestAuthor).Scan(&oldParentHandle)) + content := "A edits Y and mentions @" + oldParentHandle + " while replying to @" + newParentHandle + editRecord := fixture.replyRecord(content, newParentURI, newParentCID) + editRecord["facets"] = []interface{}{commentMentionFacet(t, content, oldParentHandle, revTestAuthor)} + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "update", key, editRevision, "bafyreiauthordeleteedited", + time.Now().Add(time.Second).UnixMicro(), editRecord, + ))) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, + commentURI, revTestAuthor, "bafyreiauthordeleteedited"), "B receives exactly one edit-added mention") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2`, commentURI, revTestAuthor), + "B must have only the mention, not the old reply") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'commentReply'`, commentURI, newParentDID), + "C keeps exactly one reply to Y after its edit") + require.Equal(t, 2, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI)) +} + +type failingCommentAuthorDeleteErasureGateRepository struct { + notifications.Repository + failure error +} + +func (repository *failingCommentAuthorDeleteErasureGateRepository) ErasureGateTx(_ context.Context, _ *sql.Tx, _ string) (bool, error) { + return false, repository.failure +} + +func TestCommentConsumer_AuthorDeleteErasureGateFailureRollsBackContentRowsAndRevision(t *testing.T) { + t.Parallel() + fixture := newCommentAuthorDeleteFixture(t) + parentURI, parentCID := fixture.parent(t, revTestAuthor) + mentionedDID, mentionedHandle := fixture.mentionRecipient(t) + key := testkit.TID() + createRevision := testkit.TID() + commentURI := fixture.createReply(t, key, createRevision, "bafyreiauthordeleterollback", + fixture.mentionedReply(t, parentURI, parentCID, mentionedDID, mentionedHandle)) + require.Equal(t, 2, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI)) + before := notificationRowsForRecordOrSubject(t, fixture.db, commentURI) + deleteRevision := testkit.TID() + require.Less(t, createRevision, deleteRevision) + injectedError := errors.New("injected author-delete erasure gate failure") + failingRepository := &failingCommentAuthorDeleteErasureGateRepository{ + Repository: postgres.NewNotificationRepository(fixture.db), failure: injectedError, + } + failingConsumer := NewCommentEventConsumer(postgres.NewCommentRepository(fixture.db), fixture.db, + WithCommentNotifications(failingRepository)) + err := failingConsumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "delete", key, deleteRevision, "", time.Now().UnixMicro(), nil, + )) + require.ErrorIs(t, err, injectedError, "delete must propagate the erasure gate failure and roll back its transaction") + var content, storedRevision string + var deletedAt sql.NullTime + require.NoError(t, fixture.db.QueryRow(`SELECT content, deleted_at FROM comments WHERE uri = $1`, commentURI). + Scan(&content, &deletedAt)) + require.Equal(t, "A replies and mentions @"+mentionedHandle, content, "rolled-back delete must preserve Y's content") + require.False(t, deletedAt.Valid, "rolled-back delete must leave Y active") + require.Equal(t, before, notificationRowsForRecordOrSubject(t, fixture.db, commentURI), + "rolled-back delete must leave every notification row unchanged") + require.NoError(t, fixture.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, commentURI).Scan(&storedRevision)) + require.Equal(t, createRevision, storedRevision, "rolled-back delete must not claim its revision") +} diff --git a/internal/atproto/jetstream/comment_notification_edit_event_time_test.go b/internal/atproto/jetstream/comment_notification_edit_event_time_test.go new file mode 100644 index 0000000..04df4c4 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_edit_event_time_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package jetstream + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCommentConsumer_MentionEdit_ZeroEventTimeNotifiesAddedMention(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + require.Zero(t, mentionEditRows(t, fixture, added, "mention")) + require.NoError(t, fixture.update(t, fixture.createdAt, 0, added)) + var cid, content, revision string + require.NoError(t, fixture.gate.db.QueryRow(`SELECT cid, content FROM comments WHERE uri = $1`, fixture.uri).Scan(&cid, &content)) + require.NoError(t, fixture.gate.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, fixture.uri).Scan(&revision)) + require.Equal(t, "bafyreimentioneditupdate", cid, "zero-time edit must apply") + require.Equal(t, "A's edited comment @"+added.handle, content) + require.Equal(t, fixture.revision, revision) + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, + fixture.uri, added.did, cid), "zero time must use index-time freshness, not the Unix epoch") +} + +func TestCommentConsumer_ActiveRecreateOldEventAppliesContentWithoutMention(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + require.Zero(t, mentionEditRows(t, fixture, added, "mention")) + var oldTime, databaseNow time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT now(), now() - INTERVAL '8 days'`).Scan(&databaseNow, &oldTime)) + require.True(t, oldTime.Before(databaseNow.Add(-7*24*time.Hour)), "fixture: re-create event predates the freshness window") + _, err := fixture.gate.db.Exec(`UPDATE comments SET indexed_at = $1 WHERE uri = $2`, oldTime.Add(-time.Hour), fixture.uri) + require.NoError(t, err) + var storedIndexedAt time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT indexed_at FROM comments WHERE uri = $1`, fixture.uri).Scan(&storedIndexedAt)) + require.True(t, storedIndexedAt.Before(oldTime), "fixture: old re-create is not rejected by a recency watermark") + event, content := activeRecreateEvent(t, fixture, fixture.createdAt, added) + event.TimeUS = oldTime.UnixMicro() + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), event)) + requireActiveRecreateApplied(t, fixture, content) + requireStoredMentionFacets(t, fixture.gate.db, fixture.uri, added.did) + var revision string + require.NoError(t, fixture.gate.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, fixture.uri).Scan(&revision)) + require.Equal(t, event.Commit.Rev, revision, "the old re-create must advance the rev") + require.Zero(t, mentionEditRows(t, fixture, added, "mention"), "an active re-create eight days old must not notify its new mention") +} diff --git a/internal/atproto/jetstream/comment_notification_erasure_test.go b/internal/atproto/jetstream/comment_notification_erasure_test.go new file mode 100644 index 0000000..22512df --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_erasure_test.go @@ -0,0 +1,211 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +type commentErasureFixture struct { + consumer *CommentEventConsumer + postURI string + postCID string + createdAt string + actorDID string + recipientDID string +} + +func newCommentErasureFixture(t *testing.T, ctx context.Context, db *sql.DB) commentErasureFixture { + t.Helper() + _, postURI, postCID := setupRevFixtures(t, db) + return commentErasureFixture{ + consumer: NewCommentEventConsumer( + postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db)), + ), + postURI: postURI, postCID: postCID, + createdAt: activatedCommentNotificationTime(t, db, ctx), + actorDID: revTestCommenter, recipientDID: revTestAuthor, + } +} + +func (fixture commentErasureFixture) reply() (*JetstreamEvent, string) { + key := testkit.TID() + record := revCommentRecord("A replies to B's post", fixture.postURI, fixture.postCID, fixture.postURI, fixture.postCID) + record["createdAt"] = fixture.createdAt + return revCommitEvent(fixture.actorDID, CommentCollection, "create", key, + testkit.TID(), "bafycommenterasure", time.Now().UnixMicro(), record), + "at://" + fixture.actorDID + "/" + CommentCollection + "/" + key +} + +// The held transaction is also the observation connection: the clone pool has +// three connections, all occupied once Delete and HandleEvent have started. +func commentErasureLockTransaction(t *testing.T, ctx context.Context, db *sql.DB, did string) (*sql.Tx, int) { + t.Helper() + connection, err := db.Conn(ctx) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, connection.Close()) }) + // Keep the fixture lock alive through worker-context cancellation, so test + // cleanup can roll it back before returning the connection to the pool. + transaction, err := connection.BeginTx(context.Background(), nil) + require.NoError(t, err) + t.Cleanup(func() { + rollbackError := transaction.Rollback() + require.True(t, rollbackError == nil || errors.Is(rollbackError, sql.ErrTxDone), + "rolling back fixture transaction: %v", rollbackError) + }) + var processID int + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&processID)) + var lockedDID string + require.NoError(t, transaction.QueryRowContext(ctx, + `SELECT did FROM users WHERE did = $1 FOR UPDATE`, did).Scan(&lockedDID)) + require.Equal(t, did, lockedDID) + return transaction, processID +} + +func commentErasureBlockedByFixture(t *testing.T, ctx context.Context, transaction *sql.Tx, fixtureProcessID int, queryFragment string) int { + t.Helper() + var blockedProcessID int + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + // pg_stat_activity's snapshot persists inside this transaction otherwise. + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + err := transaction.QueryRowContext(ctx, ` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() AND pid <> $1 + AND wait_event_type = 'Lock' + AND query ILIKE $2 + AND $1 = ANY(pg_blocking_pids(pid)) + LIMIT 1`, fixtureProcessID, "%"+queryFragment+"%").Scan(&blockedProcessID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("%s backend blocked by fixture user row lock", queryFragment)) + return blockedProcessID +} + +func commentErasureResult(t *testing.T, ctx context.Context, results <-chan error, operation string) { + t.Helper() + select { + case err := <-results: + var databaseError *pq.Error + require.False(t, errors.As(err, &databaseError) && databaseError.Code == "40P01", "%s deadlocked: %v", operation, err) + require.NoError(t, err, "%s failed", operation) + case <-ctx.Done(): + require.FailNowf(t, operation+" did not finish", "context deadline: %v", ctx.Err()) + } +} + +func TestCommentConsumer_NotificationErasure_RedriveErasedActor(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx := context.Background() + fixture := newCommentErasureFixture(t, ctx, db) + require.NoError(t, postgres.NewUserRepository(db).Delete(ctx, fixture.actorDID)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, fixture.actorDID)) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM users WHERE did = $1`, fixture.actorDID)) + + event, commentURI := fixture.reply() + require.NoError(t, fixture.consumer.HandleEvent(ctx, event)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "the accepted content-indexing gap still indexes an erased actor's comment") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE actor_did = $1`, fixture.actorDID), + "redriving an erased actor's comment must not notify the recipient") +} + +func TestCommentConsumer_NotificationErasure_DeleteFirstWaitsBeforeContent(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + fixture := newCommentErasureFixture(t, ctx, db) + priorEvent, priorURI := fixture.reply() + require.NoError(t, fixture.consumer.HandleEvent(ctx, priorEvent)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, priorURI), + "fixture: A has content before deletion starts") + + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, db, fixture.actorDID) + deleteResults := make(chan error, 1) + go func() { deleteResults <- postgres.NewUserRepository(db).Delete(ctx, fixture.actorDID) }() + deleteProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "DELETE FROM users") + + event, _ := fixture.reply() + consumerResults := make(chan error, 1) + go func() { consumerResults <- fixture.consumer.HandleEvent(ctx, event) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + var waitingBeforeContent bool + err := transaction.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid NOT IN ($1, $2) + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND NOT EXISTS (SELECT 1 FROM pg_locks other + WHERE other.pid = waiter.pid AND NOT other.granted + AND other.locktype IN ('tuple', 'transactionid')) + AND NOT EXISTS (SELECT 1 FROM pg_locks content + WHERE content.pid = waiter.pid + AND content.relation IN ('comments'::regclass, 'posts'::regclass)) + )`, deleteProcessID, fixtureProcessID).Scan(&waitingBeforeContent) + return waitingBeforeContent, err + }, testkit.WithDescription("consumer waiting for Delete's erasure advisory lock before reading comments or posts")) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, deleteResults, "Delete(A)") + commentErasureResult(t, ctx, consumerResults, "HandleEvent(A)") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE actor_did = $1`, fixture.actorDID), + "Delete-first interleaving must leave no reply notifications from erased A") +} + +func TestCommentConsumer_NotificationErasure_ConsumerFirstPinsDelete(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + fixture := newCommentErasureFixture(t, ctx, db) + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, db, fixture.recipientDID) + event, commentURI := fixture.reply() + consumerResults := make(chan error, 1) + go func() { consumerResults <- fixture.consumer.HandleEvent(ctx, event) }() + consumerProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "INSERT INTO notifications") + + deleteResults := make(chan error, 1) + go func() { deleteResults <- postgres.NewUserRepository(db).Delete(ctx, fixture.actorDID) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + var deleteWaitsForConsumer bool + err := transaction.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid NOT IN ($1, $2) + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ShareLock' + AND NOT waiter.granted AND waiter.mode = 'ExclusiveLock' + )`, consumerProcessID, fixtureProcessID).Scan(&deleteWaitsForConsumer) + return deleteWaitsForConsumer, err + }, testkit.WithDescription("Delete(A) waiting on consumer's granted shared erasure advisory lock")) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, consumerResults, "HandleEvent(A)") + commentErasureResult(t, ctx, deleteResults, "Delete(A)") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE actor_did = $1`, fixture.actorDID), + "Delete must remove A's committed reply notification") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "Delete must remove A's newly indexed comment") +} diff --git a/internal/atproto/jetstream/comment_notification_gates_test.go b/internal/atproto/jetstream/comment_notification_gates_test.go new file mode 100644 index 0000000..1216488 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_gates_test.go @@ -0,0 +1,233 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/core/users" + "Coves/internal/crypto/credentialcipher/credentialciphertest" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type notificationGatePost struct { + authorDID string + uri string + cid string +} + +type notificationGateFixture struct { + db *sql.DB + commenterDID string + posts []notificationGatePost +} + +// Each fixture owns its activation row and indexed post(s), so its time gates +// cannot change the meaning of a parallel case's notification assertion. The +// commenter is indexed on commenterPDS and the control recipient on a native PDS. +func newNotificationGateFixture(t *testing.T, recipientPDS, commenterPDS string, controlRecipient bool) notificationGateFixture { + t.Helper() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + commenterDID := "did:plc:" + uniqueID + "commenter" + communityDID := "did:plc:" + uniqueID + "community" + recipients := []struct{ did, handle, pdsURL string }{ + {"did:plc:" + uniqueID + "recipient", uniqueID + "recipient.test", recipientPDS}, + } + if controlRecipient { + recipients = append(recipients, struct{ did, handle, pdsURL string }{ + "did:plc:" + uniqueID + "control", uniqueID + "control.test", bridgedTestNativePDS, + }) + } + for _, user := range append(recipients, struct{ did, handle, pdsURL string }{ + commenterDID, uniqueID + "commenter.test", commenterPDS, + }) { + _, err := db.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + user.did, user.handle, user.pdsURL) + require.NoError(t, err, "index notification recipient or commenter") + } + _, err := db.ExecContext(ctx, + `INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, pds_url, created_at) + VALUES ($1, $2, $3, $4, $4, $4, $5, NOW())`, + communityDID, uniqueID+"community.test", "Notification gate community", recipients[0].did, bridgedTestNativePDS) + require.NoError(t, err, "index the posts' community") + + userService := newMockUserService() + for _, recipient := range recipients { + userService.users[recipient.did] = &users.User{DID: recipient.did, Handle: recipient.handle} + } + postConsumer := NewPostEventConsumer( + postgres.NewPostRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), + userService, db, WithAdmissions(postgres.NewAdmissionRepository(db)), + ) + fixture := notificationGateFixture{db: db, commenterDID: commenterDID} + for _, recipient := range recipients { + postKey := testkit.TID() + post := notificationGatePost{authorDID: recipient.did, uri: pv2URI(recipient.did, postKey), cid: "bafyreicommentnotificationgatepost"} + require.NoError(t, postConsumer.HandleEvent(ctx, pv2Event( + recipient.did, "create", postKey, testkit.TID(), post.cid, time.Now().UnixMicro(), + pv2Record(communityDID, "Reply notification gate target", "A post to reply to"), + )), "index recipient's author-owned post") + require.Equal(t, 1, countRows(t, db, + `SELECT count(*) FROM posts WHERE uri = $1 AND author_did = $2`, post.uri, recipient.did)) + fixture.posts = append(fixture.posts, post) + } + return fixture +} + +func (fixture notificationGateFixture) commentConsumer(options ...CommentEventConsumerOption) *CommentEventConsumer { + return NewCommentEventConsumer(postgres.NewCommentRepository(fixture.db), fixture.db, + append([]CommentEventConsumerOption{WithCommentNotifications(postgres.NewNotificationRepository(fixture.db))}, options...)...) +} + +func replyForNotificationGate(t *testing.T, fixture notificationGateFixture, consumer *CommentEventConsumer, post notificationGatePost, createdAt string) string { + t.Helper() + commentKey := testkit.TID() + commentURI := "at://" + fixture.commenterDID + "/" + CommentCollection + "/" + commentKey + commentRecord := revCommentRecord("A replies to the recipient's post", post.uri, post.cid, post.uri, post.cid) + commentRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent( + fixture.commenterDID, CommentCollection, "create", commentKey, testkit.TID(), + "bafyreicommentnotificationgatereply", time.Now().UnixMicro(), commentRecord, + )), "index A's top-level reply") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "the reply must be indexed even if its notification is suppressed") + return commentURI +} + +func TestCommentConsumer_NotificationWriteGates(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + }{ + {"a_erased_recipient"}, + {"b_aggregator_recipient"}, + {"d1_recipient_blocks_commenter"}, + {"d2_commenter_blocks_recipient"}, + {"f_older_than_seven_days"}, + {"g_before_activation"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newNotificationGateFixture(t, bridgedTestNativePDS, bridgedTestNativePDS, false) + post := fixture.posts[0] + createdAt := activatedCommentNotificationTime(t, fixture.db, ctx) + switch test.name { + case "a_erased_recipient": + _, err := fixture.db.ExecContext(ctx, `INSERT INTO deleted_accounts (did) VALUES ($1)`, post.authorDID) + require.NoError(t, err, "retain both the users row and the erasure marker") + case "b_aggregator_recipient": + _, err := fixture.db.ExecContext(ctx, + `INSERT INTO aggregators (did, display_name, record_uri, record_cid) VALUES ($1, $2, $3, $4)`, + post.authorDID, "Aggregator recipient", "at://"+post.authorDID+"/social.coves.aggregator.service/self", "bafyreigateservice") + require.NoError(t, err, "retain both the users row and the aggregator declaration") + case "d1_recipient_blocks_commenter", "d2_commenter_blocks_recipient": + blockerDID, blockedDID := post.authorDID, fixture.commenterDID + if test.name == "d2_commenter_blocks_recipient" { + blockerDID, blockedDID = blockedDID, blockerDID + } + _, err := fixture.db.ExecContext(ctx, + `INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) VALUES ($1, $2, $3, $4)`, + blockerDID, blockedDID, "at://"+blockerDID+"/"+CovesActorBlockCollection+"/"+testkit.TID(), "bafyreigateblock") + require.NoError(t, err, "index the pre-existing directional block") + case "f_older_than_seven_days", "g_before_activation": + var databaseNow time.Time + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT NOW()`).Scan(&databaseNow)) + var activationTime, recordTime time.Time + if test.name == "f_older_than_seven_days" { + activationTime, recordTime = databaseNow.Add(-30*24*time.Hour), databaseNow.Add(-8*24*time.Hour) + } else { + activationTime, recordTime = databaseNow.Add(-time.Hour), databaseNow.Add(-2*time.Hour) + } + _, err := fixture.db.ExecContext(ctx, `UPDATE notification_activation SET activated_at = $1`, activationTime) + require.NoError(t, err) + createdAt = recordTime.UTC().Format(time.RFC3339Nano) + } + commentURI := replyForNotificationGate(t, fixture, fixture.commentConsumer(), post, createdAt) + require.Zero(t, countRows(t, fixture.db, + `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "the reply to an ineligible recipient must not write any notification") + }) + } + + // Only the recipient's hosting suppresses: the commenter is also hosted on + // the trusted bridge, so the native control proves a bridged actor still + // notifies a native recipient. + t.Run("c_bridge_hosted_recipient_and_untrusted_control", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newNotificationGateFixture(t, bridgedTestPDS, bridgedTestPDS, true) + createdAt := activatedCommentNotificationTime(t, fixture.db, ctx) + consumer := fixture.commentConsumer(WithCommentBridgeTrust(NewBridgeTrust([]string{bridgedTestPDS}))) + bridgePost, controlPost := fixture.posts[0], fixture.posts[1] + bridgeReplyURI := replyForNotificationGate(t, fixture, consumer, bridgePost, createdAt) + assert.Zero(t, countRows(t, fixture.db, + `SELECT count(*) FROM notifications WHERE record_uri = $1`, bridgeReplyURI), + "a reply to a trusted bridge-hosted recipient must not write any notification") + + controlReplyURI := replyForNotificationGate(t, fixture, consumer, controlPost, createdAt) + require.Equal(t, 1, countRows(t, fixture.db, + `SELECT count(*) FROM notifications WHERE recipient_did = $1 AND reason = 'postReply' AND actor_did = $2 AND record_uri = $3`, + controlPost.authorDID, fixture.commenterDID, controlReplyURI), + "a native-PDS recipient must still receive the postReply from a bridge-hosted commenter") + }) + + t.Run("e_later_block_keeps_existing_notification", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newNotificationGateFixture(t, bridgedTestNativePDS, bridgedTestNativePDS, false) + post := fixture.posts[0] + createdAt := activatedCommentNotificationTime(t, fixture.db, ctx) + commentURI := replyForNotificationGate(t, fixture, fixture.commentConsumer(), post, createdAt) + + type storedNotification struct { + id int64 + recipientDID string + reason string + actorDID string + recordURI string + recordCID string + subjectURI string + rootPostURI string + recordCreatedAt time.Time + sortAt time.Time + } + readNotification := func() storedNotification { + t.Helper() + var notification storedNotification + require.NoError(t, fixture.db.QueryRowContext(ctx, + `SELECT id, recipient_did, reason, actor_did, record_uri, record_cid, subject_uri, + root_post_uri, record_created_at, sort_at FROM notifications + WHERE recipient_did = $1 AND reason = 'postReply' AND record_uri = $2`, + post.authorDID, commentURI, + ).Scan(¬ification.id, ¬ification.recipientDID, ¬ification.reason, + ¬ification.actorDID, ¬ification.recordURI, ¬ification.recordCID, + ¬ification.subjectURI, ¬ification.rootPostURI, + ¬ification.recordCreatedAt, ¬ification.sortAt)) + return notification + } + beforeBlock := readNotification() + require.Equal(t, fixture.commenterDID, beforeBlock.actorDID) + require.Equal(t, post.uri, beforeBlock.subjectURI) + + blockConsumer := NewUserEventConsumer(nil, nil, + WithUserBlockRepo(postgres.NewUserBlockRepository(fixture.db))) + require.NoError(t, blockConsumer.HandleEvent(ctx, + userBlockEvent(post.authorDID, testkit.TID(), "create", fixture.commenterDID))) + require.Equal(t, 1, countRows(t, fixture.db, + `SELECT count(*) FROM user_blocks WHERE blocker_did = $1 AND blocked_did = $2`, + post.authorDID, fixture.commenterDID), "the real block consumer must index B's block of A") + require.Equal(t, beforeBlock, readNotification(), "a later block must not erase or change an existing postReply") + }) +} diff --git a/internal/atproto/jetstream/comment_notification_mention_cap_test.go b/internal/atproto/jetstream/comment_notification_mention_cap_test.go new file mode 100644 index 0000000..affd2d6 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_mention_cap_test.go @@ -0,0 +1,186 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func mentionCapRecipients(t *testing.T, fixture *mentionEditFixture, count int) []mentionEditRecipient { + t.Helper() + recipients := make([]mentionEditRecipient, count) + for index := range recipients { + recipients[index] = fixture.recipient(t) + } + return recipients +} + +func mentionCapRows(t *testing.T, fixture mentionEditFixture) map[string]int64 { + t.Helper() + rows, err := fixture.gate.db.QueryContext(context.Background(), + `SELECT recipient_did, id FROM notifications WHERE record_uri = $1 AND reason = 'mention'`, fixture.uri) + require.NoError(t, err) + defer rows.Close() + got := make(map[string]int64) + for rows.Next() { + var recipientDID string + var notificationID int64 + require.NoError(t, rows.Scan(&recipientDID, ¬ificationID)) + got[recipientDID] = notificationID + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + return got +} + +func requireMentionCapRecipients(t *testing.T, rows map[string]int64, expected []mentionEditRecipient) { + t.Helper() + require.Equal(t, len(expected), len(rows), "record mention count must respect the total cap") + for index, recipient := range expected { + _, exists := rows[recipient.did] + require.True(t, exists, "eligible facet recipient %d must have a mention row", index) + } +} + +func requireMentionCapUnchanged(t *testing.T, before, after map[string]int64) { + t.Helper() + require.Equal(t, len(before), len(after), "historical mention count must remain unchanged") + for recipientDID, notificationID := range before { + actualID, exists := after[recipientDID] + require.True(t, exists, "historical mention recipient must retain a row") + require.Equal(t, notificationID, actualID, "historical mention row id must remain unchanged") + } +} + +func TestCommentConsumer_MentionCap_CreateKeepsFirstTenFacetRecipients(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + recipients := mentionCapRecipients(t, &fixture, 12) + fixture.create(t, recipients...) + requireMentionCapRecipients(t, mentionCapRows(t, fixture), recipients[:10]) +} + +func TestCommentConsumer_MentionCap_EditCannotExceedFullBudget(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + recipients := mentionCapRecipients(t, &fixture, 12) + added := mentionCapRecipients(t, &fixture, 3) + fixture.create(t, recipients...) + before := mentionCapRows(t, fixture) + requireMentionCapRecipients(t, before, recipients[:10]) + updated := append(append([]mentionEditRecipient{}, recipients...), added...) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), updated...)) + requireMentionCapUnchanged(t, before, mentionCapRows(t, fixture)) +} + +func TestCommentConsumer_MentionCap_RemovingFacetsDoesNotFreeHistoricalRows(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + original := mentionCapRecipients(t, &fixture, 10) + replacement := mentionCapRecipients(t, &fixture, 10) + fixture.create(t, original...) + before := mentionCapRows(t, fixture) + requireMentionCapRecipients(t, before, original) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), replacement...)) + requireMentionCapUnchanged(t, before, mentionCapRows(t, fixture)) +} + +func TestCommentConsumer_MentionCap_DeletedNotificationFreesOneSlotInFacetOrder(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + original := mentionCapRecipients(t, &fixture, 10) + added := mentionCapRecipients(t, &fixture, 2) + fixture.create(t, original...) + before := mentionCapRows(t, fixture) + requireMentionCapRecipients(t, before, original) + result, err := fixture.gate.db.ExecContext(context.Background(), + `DELETE FROM notifications WHERE record_uri = $1 AND reason = 'mention' AND id = $2`, fixture.uri, before[original[0].did]) + require.NoError(t, err) + deleted, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, deleted, "fixture: remove exactly one stored mention") + withoutDeleted := mentionCapRows(t, fixture) + requireMentionCapRecipients(t, withoutDeleted, original[1:]) + updated := append(append([]mentionEditRecipient{}, original...), added...) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), updated...)) + after := mentionCapRows(t, fixture) + expected := append(append([]mentionEditRecipient{}, original[1:]...), added[0]) + requireMentionCapRecipients(t, after, expected) + for did, id := range withoutDeleted { + require.Equal(t, id, after[did], "the other historical mention rows must keep their ids") + } + require.NotZero(t, after[added[0].did], "the first new eligible facet takes the freed slot") + _, secondAdded := after[added[1].did] + require.False(t, secondAdded, "the second new facet cannot take an eleventh slot") +} + +func TestCommentConsumer_MentionCap_ActiveRecreateCannotExceedFullBudget(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + original := mentionCapRecipients(t, &fixture, 10) + added := mentionCapRecipients(t, &fixture, 2) + fixture.create(t, original...) + before := mentionCapRows(t, fixture) + requireMentionCapRecipients(t, before, original) + updated := append(append([]mentionEditRecipient{}, original...), added...) + event, content := activeRecreateEvent(t, fixture, fixture.createdAt, updated...) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), event)) + requireActiveRecreateApplied(t, fixture, content) + requireMentionCapUnchanged(t, before, mentionCapRows(t, fixture)) +} + +func TestCommentConsumer_MentionCap_ResurrectionBudgetCarriesOverKeptRows(t *testing.T) { + t.Parallel() + for _, originalCount := range []int{10, 8} { + t.Run(fmt.Sprintf("original_%d", originalCount), func(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + original := mentionCapRecipients(t, &fixture, originalCount) + added := mentionCapRecipients(t, &fixture, 12-originalCount) + fixture.create(t, original...) + before := mentionCapRows(t, fixture) + originalRows := notificationRowsForRecordOrSubject(t, fixture.gate.db, fixture.uri) + requireMentionCapRecipients(t, before, original) + deleteRevision := testkit.TID() + require.Less(t, fixture.revision, deleteRevision, "fixture: delete revision must be newer than create") + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + fixture.gate.commenterDID, CommentCollection, "delete", fixture.key, deleteRevision, + "", time.Now().Add(time.Second).UnixMicro(), nil, + ))) + var deletedAt sql.NullTime + require.NoError(t, fixture.gate.db.QueryRowContext(context.Background(), + `SELECT deleted_at FROM comments WHERE uri = $1`, fixture.uri).Scan(&deletedAt)) + require.True(t, deletedAt.Valid, "fixture: the comment must be soft-deleted") + requireMentionCapUnchanged(t, before, mentionCapRows(t, fixture)) + recreateRevision := testkit.TID() + require.Less(t, deleteRevision, recreateRevision, "fixture: resurrection revision must be newer than delete") + const recreatedCID = "bafyreimentioncapresurrection" + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + fixture.gate.commenterDID, CommentCollection, "create", fixture.key, recreateRevision, + recreatedCID, time.Now().Add(2*time.Second).UnixMicro(), fixture.record(t, fixture.createdAt, append(added, original...)...), + ))) + var storedCID string + require.NoError(t, fixture.gate.db.QueryRowContext(context.Background(), + `SELECT cid FROM comments WHERE uri = $1 AND deleted_at IS NULL`, fixture.uri).Scan(&storedCID)) + require.Equal(t, recreatedCID, storedCID, "fixture: the same-parent resurrection must be indexed") + after := mentionCapRows(t, fixture) + expected := append(append([]mentionEditRecipient{}, original...), added[:10-originalCount]...) + requireMentionCapRecipients(t, after, expected) + for did, id := range before { + require.Equal(t, id, after[did], "original mention must retain its id") + } + allRows := notificationRowsForRecordOrSubject(t, fixture.gate.db, fixture.uri) + for _, row := range originalRows { + require.Contains(t, allRows, row, "kept row must retain its original CID, subject, root and sort time") + } + }) + } +} diff --git a/internal/atproto/jetstream/comment_notification_mention_edits_test.go b/internal/atproto/jetstream/comment_notification_mention_edits_test.go new file mode 100644 index 0000000..a1351b4 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_mention_edits_test.go @@ -0,0 +1,559 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mentionEditRecipient struct { + did, handle string +} + +type mentionEditFixture struct { + gate notificationGateFixture + consumer *CommentEventConsumer + post notificationGatePost + key, uri string + revision string + createdAt string + parentURI string + parentCID string +} + +func newMentionEditFixture(t *testing.T) mentionEditFixture { + t.Helper() + gate := newNotificationGateFixture(t, bridgedTestNativePDS, bridgedTestNativePDS, false) + return mentionEditFixture{ + gate: gate, consumer: gate.commentConsumer(), post: gate.posts[0], + key: testkit.TID(), createdAt: activatedCommentNotificationTime(t, gate.db, context.Background()), + parentURI: gate.posts[0].uri, parentCID: gate.posts[0].cid, + } +} + +func (fixture *mentionEditFixture) recipient(t *testing.T) mentionEditRecipient { + t.Helper() + return fixture.recipientOnPDS(t, bridgedTestNativePDS) +} + +func (fixture *mentionEditFixture) recipientOnPDS(t *testing.T, pdsURL string) mentionEditRecipient { + t.Helper() + id := testkit.UniqueID(t) + recipient := mentionEditRecipient{did: "did:plc:" + id + "mentioned", handle: id + "mentioned.test"} + _, err := fixture.gate.db.ExecContext(context.Background(), + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + recipient.did, recipient.handle, pdsURL) + require.NoError(t, err, "fixture: the mention recipient must be indexed") + return recipient +} + +func (fixture *mentionEditFixture) postAuthor(t *testing.T) mentionEditRecipient { + t.Helper() + var handle string + require.NoError(t, fixture.gate.db.QueryRowContext(context.Background(), + `SELECT handle FROM users WHERE did = $1`, fixture.post.authorDID).Scan(&handle)) + return mentionEditRecipient{did: fixture.post.authorDID, handle: handle} +} + +func (fixture *mentionEditFixture) record(t *testing.T, createdAt string, recipients ...mentionEditRecipient) map[string]interface{} { + t.Helper() + content := "A's edited comment" + for _, recipient := range recipients { + content += " @" + recipient.handle + } + record := revCommentRecord(content, fixture.post.uri, fixture.post.cid, fixture.parentURI, fixture.parentCID) + record["createdAt"] = createdAt + if len(recipients) > 0 { + facets := make([]interface{}, 0, len(recipients)) + for _, recipient := range recipients { + facets = append(facets, commentMentionFacet(t, content, recipient.handle, recipient.did)) + } + record["facets"] = facets + } + return record +} + +func (fixture *mentionEditFixture) create(t *testing.T, recipients ...mentionEditRecipient) { + t.Helper() + fixture.uri = "at://" + fixture.gate.commenterDID + "/" + CommentCollection + "/" + fixture.key + fixture.revision = testkit.TID() + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + fixture.gate.commenterDID, CommentCollection, "create", fixture.key, fixture.revision, + "bafyreimentioneditcreate", time.Now().UnixMicro(), fixture.record(t, fixture.createdAt, recipients...), + )), "fixture: index the original comment before editing") + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM comments WHERE uri = $1`, fixture.uri)) + if len(recipients) > 0 { + dids := make([]string, 0, len(recipients)) + for _, recipient := range recipients { + dids = append(dids, recipient.did) + } + requireStoredMentionFacets(t, fixture.gate.db, fixture.uri, dids...) + } +} + +func (fixture *mentionEditFixture) update(t *testing.T, createdAt string, eventTime int64, recipients ...mentionEditRecipient) error { + t.Helper() + revision := testkit.TID() + require.Less(t, fixture.revision, revision, "fixture: update revision must be newer") + fixture.revision = revision + return fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + fixture.gate.commenterDID, CommentCollection, "update", fixture.key, revision, + "bafyreimentioneditupdate", eventTime, fixture.record(t, createdAt, recipients...), + )) +} + +func mentionEditRows(t *testing.T, fixture mentionEditFixture, recipient mentionEditRecipient, reason string) int { + t.Helper() + return countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = $3`, fixture.uri, recipient.did, reason) +} + +// Hold only the comment row in the two concurrent-update guards. The fixture +// connection also observes pg_stat_activity while the other two pool connections +// run HandleEvent and any in-flight query. +func mentionEditRowTransaction(t *testing.T, ctx context.Context, db *sql.DB) (*sql.Tx, int) { + t.Helper() + connection, err := db.Conn(ctx) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, connection.Close()) }) + transaction, err := connection.BeginTx(context.Background(), nil) + require.NoError(t, err) + t.Cleanup(func() { + rollbackError := transaction.Rollback() + require.True(t, rollbackError == nil || errors.Is(rollbackError, sql.ErrTxDone), + "rolling back comment-row fixture transaction: %v", rollbackError) + }) + var processID int + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&processID)) + return transaction, processID +} + +func TestCommentConsumer_MentionEdit_AddRemoveAndReadd(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + postAuthor := fixture.postAuthor(t) + kept := fixture.recipient(t) + added := fixture.recipient(t) + fixture.create(t, kept) + require.Equal(t, 1, mentionEditRows(t, fixture, kept, "mention"), "fixture: B received the original mention") + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), kept, added)) + assert.Equal(t, 1, mentionEditRows(t, fixture, kept, "mention"), "keeping B must not duplicate B's mention") + require.Equal(t, 1, mentionEditRows(t, fixture, added, "mention"), "edit-added E must receive exactly one mention") + var actorDID, recordCID, rootPostURI string + var subjectURI sql.NullString + var recordCreatedAt time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT actor_did, record_cid, subject_uri, root_post_uri, record_created_at + FROM notifications WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, fixture.uri, added.did). + Scan(&actorDID, &recordCID, &subjectURI, &rootPostURI, &recordCreatedAt)) + assert.Equal(t, fixture.gate.commenterDID, actorDID) + assert.Equal(t, "bafyreimentioneditupdate", recordCID, "an edit mention points to the new CID") + assert.False(t, subjectURI.Valid) + assert.Equal(t, fixture.post.uri, rootPostURI) + createdAt, err := time.Parse(time.RFC3339Nano, fixture.createdAt) + require.NoError(t, err) + assert.True(t, recordCreatedAt.Equal(createdAt)) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(2*time.Second).UnixMicro(), added)) + assert.Equal(t, 1, mentionEditRows(t, fixture, kept, "mention"), "removing B must retain its historical notification") + assert.Equal(t, 1, mentionEditRows(t, fixture, added, "mention")) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(3*time.Second).UnixMicro(), added, kept)) + assert.Equal(t, 1, mentionEditRows(t, fixture, kept, "mention"), "re-adding B must not duplicate its existing row") + assert.Equal(t, 1, mentionEditRows(t, fixture, added, "mention")) + assert.Equal(t, 1, mentionEditRows(t, fixture, postAuthor, "postReply")) +} + +func TestCommentConsumer_MentionEdit_DiffUsesStoredFacets(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + blocked := fixture.recipient(t) + added := fixture.recipient(t) + blockURI := "at://" + blocked.did + "/" + CovesActorBlockCollection + "/" + testkit.TID() + _, err := fixture.gate.db.ExecContext(context.Background(), + `INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) VALUES ($1, $2, $3, $4)`, + blocked.did, fixture.gate.commenterDID, blockURI, "bafyreimentioneditblock") + require.NoError(t, err) + fixture.create(t, blocked) + require.Zero(t, mentionEditRows(t, fixture, blocked, "mention"), "fixture: B is blocked but present in stored facets") + _, err = fixture.gate.db.ExecContext(context.Background(), `DELETE FROM user_blocks WHERE record_uri = $1`, blockURI) + require.NoError(t, err) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), blocked, added)) + assert.Equal(t, 1, mentionEditRows(t, fixture, added, "mention"), "newly added eligible E must be notified") + assert.Zero(t, mentionEditRows(t, fixture, blocked, "mention"), "B was already in stored facets even though its original notification was blocked") +} + +// The diff baseline is read under the comment row lock, so facets committed +// while the edit waits for that lock count as already mentioned. +func TestCommentConsumer_MentionEdit_DiffUsesLockedStoredFacets(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + concurrent := fixture.recipient(t) + added := fixture.recipient(t) + fixture.create(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + revision := testkit.TID() + require.Less(t, fixture.revision, revision, "fixture: the edit must pass the revision gate") + event := revCommitEvent(fixture.gate.commenterDID, CommentCollection, "update", fixture.key, + revision, "bafyreimentioneditlocked", time.Now().Add(time.Second).UnixMicro(), + fixture.record(t, fixture.createdAt, concurrent, added)) + concurrentContent := "A's comment @" + concurrent.handle + concurrentFacets, err := json.Marshal([]interface{}{commentMentionFacet(t, concurrentContent, concurrent.handle, concurrent.did)}) + require.NoError(t, err) + + results := make(chan error, 1) + started, finished := false, false + t.Cleanup(func() { + if started && !finished { + commentErasureResult(t, ctx, results, "HandleEvent(update) after fixture rollback") + } + }) + transaction, processID := mentionEditRowTransaction(t, ctx, fixture.gate.db) + _, err = transaction.ExecContext(ctx, `UPDATE comments SET content_facets = $1::jsonb WHERE uri = $2`, string(concurrentFacets), fixture.uri) + require.NoError(t, err) + started = true + go func() { results <- fixture.consumer.HandleEvent(ctx, event) }() + commentErasureBlockedByFixture(t, ctx, transaction, processID, "comments") + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(update)") + finished = true + assert.Zero(t, mentionEditRows(t, fixture, concurrent, "mention"), "E was in the facets committed while the edit waited for the row lock") + assert.Equal(t, 1, mentionEditRows(t, fixture, added, "mention"), "newly added D must be notified") +} + +func TestCommentConsumer_MentionEdit_TrustedBridgeRecipientExcluded(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + fixture.consumer = fixture.gate.commentConsumer(WithCommentBridgeTrust(NewBridgeTrust([]string{bridgedTestPDS}))) + bridged := fixture.recipientOnPDS(t, bridgedTestPDS) + control := fixture.recipient(t) + fixture.create(t) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), bridged, control)) + assert.Zero(t, mentionEditRows(t, fixture, bridged, "mention"), "a recipient on a trusted bridge PDS must not be notified") + assert.Equal(t, 1, mentionEditRows(t, fixture, control, "mention"), "the native control must be notified") +} + +func TestCommentConsumer_MentionEdit_ReplyRecipientDeduplicated(t *testing.T) { + t.Parallel() + t.Run("top-level SQL NULL facets", func(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + postAuthor := fixture.postAuthor(t) + added := fixture.recipient(t) + fixture.create(t) + var facets sql.NullString + require.NoError(t, fixture.gate.db.QueryRow(`SELECT content_facets FROM comments WHERE uri = $1`, fixture.uri).Scan(&facets)) + require.False(t, facets.Valid, "fixture: absent facets must be SQL NULL") + require.Equal(t, 1, mentionEditRows(t, fixture, postAuthor, "postReply")) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), postAuthor, added)) + assert.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, fixture.uri, postAuthor.did), "reply recipient B keeps only its postReply") + assert.Equal(t, 1, mentionEditRows(t, fixture, postAuthor, "postReply")) + assert.Equal(t, 1, mentionEditRows(t, fixture, added, "mention"), "D must receive the edit-added mention") + }) + t.Run("nested comment reply", func(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + parent := fixture.recipient(t) + added := fixture.recipient(t) + parentKey := testkit.TID() + fixture.parentURI = "at://" + parent.did + "/" + CommentCollection + "/" + parentKey + fixture.parentCID = "bafyreimentioneditparent" + parentRecord := revCommentRecord("C comments on B's post", fixture.post.uri, fixture.post.cid, fixture.post.uri, fixture.post.cid) + parentRecord["createdAt"] = fixture.createdAt + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), revCommitEvent( + parent.did, CommentCollection, "create", parentKey, testkit.TID(), fixture.parentCID, + time.Now().UnixMicro(), parentRecord, + )), "fixture: index C's parent comment") + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM comments WHERE uri = $1`, fixture.parentURI)) + fixture.create(t) + require.Equal(t, 1, mentionEditRows(t, fixture, parent, "commentReply")) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), parent, added)) + assert.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, fixture.uri, parent.did), "parent C keeps only its commentReply") + assert.Equal(t, 1, mentionEditRows(t, fixture, parent, "commentReply")) + assert.Equal(t, 1, mentionEditRows(t, fixture, added, "mention"), "D must receive the edit-added mention") + }) +} + +func TestCommentConsumer_MentionEdit_OldRecordUsesEditIndexTime(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + postAuthor := fixture.postAuthor(t) + added := fixture.recipient(t) + var now time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT now()`).Scan(&now)) + _, err := fixture.gate.db.Exec(`UPDATE notification_activation SET activated_at = $1`, now.Add(-40*24*time.Hour)) + require.NoError(t, err) + fixture.createdAt = now.Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339Nano) + fixture.create(t) + require.Zero(t, mentionEditRows(t, fixture, postAuthor, "postReply"), "fixture: old reply is freshness-suppressed, though B is eligible") + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), added)) + assert.Equal(t, 1, mentionEditRows(t, fixture, added, "mention"), "an edit to an old comment must notify newly mentioned D") +} + +// The resolved reply recipient never gets a mention for the comment, even when +// its reply notification was suppressed at create. +func TestCommentConsumer_MentionEdit_SuppressedReplyRecipientNotMentioned(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + postAuthor := fixture.postAuthor(t) + added := fixture.recipient(t) + blockURI := "at://" + postAuthor.did + "/" + CovesActorBlockCollection + "/" + testkit.TID() + _, err := fixture.gate.db.ExecContext(context.Background(), + `INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) VALUES ($1, $2, $3, $4)`, + postAuthor.did, fixture.gate.commenterDID, blockURI, "bafyreimentioneditreplyblock") + require.NoError(t, err) + fixture.create(t) + require.Zero(t, mentionEditRows(t, fixture, postAuthor, "postReply"), "fixture: B's block suppresses the create's postReply") + _, err = fixture.gate.db.ExecContext(context.Background(), `DELETE FROM user_blocks WHERE record_uri = $1`, blockURI) + require.NoError(t, err) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), postAuthor, added)) + assert.Equal(t, 1, mentionEditRows(t, fixture, added, "mention"), "D must receive the edit-added mention") + assert.Zero(t, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, fixture.uri, postAuthor.did), "B is the resolved reply recipient even with no postReply row") +} + +func TestCommentConsumer_MentionEdit_OldJetstreamTimestampSuppressesAddedMention(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + var oldTime, databaseNow time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT now(), now() - INTERVAL '8 days'`).Scan(&databaseNow, &oldTime)) + require.True(t, oldTime.Before(databaseNow.Add(-7*24*time.Hour)), "fixture: Jetstream time predates the freshness window") + _, err := fixture.gate.db.Exec(`UPDATE comments SET indexed_at = $1 WHERE uri = $2`, oldTime.Add(-time.Hour), fixture.uri) + require.NoError(t, err, "fixture: the old edit time must still beat the row's recency watermark") + var storedIndexedAt time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT indexed_at FROM comments WHERE uri = $1`, fixture.uri).Scan(&storedIndexedAt)) + require.True(t, storedIndexedAt.Before(oldTime), "fixture: the edit must pass the recency guard") + require.NoError(t, fixture.update(t, fixture.createdAt, oldTime.UnixMicro(), added)) + var cid, content, revision string + require.NoError(t, fixture.gate.db.QueryRow(`SELECT cid, content FROM comments WHERE uri = $1`, fixture.uri).Scan(&cid, &content)) + require.NoError(t, fixture.gate.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, fixture.uri).Scan(&revision)) + require.Equal(t, "bafyreimentioneditupdate", cid, "old edit must still replace the CID") + require.Equal(t, "A's edited comment @"+added.handle, content, "old edit must still replace content") + require.Equal(t, fixture.revision, revision, "old edit must still advance the rev") + assert.Zero(t, mentionEditRows(t, fixture, added, "mention"), "an edit event eight days old must not notify its new mention") +} + +func TestCommentConsumer_MentionEdit_ActivationUsesStoredCreatedAt(t *testing.T) { + t.Parallel() + t.Run("stored before activation", func(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + var activation time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT activated_at FROM notification_activation`).Scan(&activation)) + fixture.createdAt = activation.Add(-time.Minute).UTC().Format(time.RFC3339Nano) + fixture.create(t) + require.Zero(t, countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, fixture.uri)) + incomingCreatedAt := time.Now().UTC().Format(time.RFC3339Nano) + require.NoError(t, fixture.update(t, incomingCreatedAt, time.Now().Add(time.Second).UnixMicro(), added)) + assert.Zero(t, mentionEditRows(t, fixture, added, "mention"), "the incoming createdAt cannot activate a pre-activation stored comment") + }) + t.Run("stored after activation", func(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + storedCreatedAt, err := time.Parse(time.RFC3339Nano, fixture.createdAt) + require.NoError(t, err) + require.NoError(t, fixture.update(t, storedCreatedAt.Add(-24*time.Hour).UTC().Format(time.RFC3339Nano), time.Now().Add(time.Second).UnixMicro(), added)) + require.Equal(t, 1, mentionEditRows(t, fixture, added, "mention"), "stored createdAt after activation must permit the edit mention") + var recordCreatedAt time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT record_created_at FROM notifications WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, fixture.uri, added.did).Scan(&recordCreatedAt)) + assert.True(t, recordCreatedAt.Equal(storedCreatedAt), "record_created_at must equal the stored timestamp") + }) +} + +func TestCommentConsumer_MentionEdit_NonqualifyingUpdates(t *testing.T) { + t.Parallel() + for _, name := range []string{"older revision", "older event time", "already deleted", "concurrent delete", "concurrent recency loss"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + notificationCount := countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, fixture.uri) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + baseTime := time.Now().Add(time.Second).UnixMicro() + revision := testkit.TID() + require.Less(t, fixture.revision, revision) + contentBefore := "A's edited comment" + event := revCommitEvent(fixture.gate.commenterDID, CommentCollection, "update", fixture.key, + revision, "bafyreimentioneditguard", baseTime, fixture.record(t, fixture.createdAt, added)) + switch name { + case "older revision": + event.Commit.Rev = revA + require.Less(t, event.Commit.Rev, fixture.revision) + require.NoError(t, fixture.consumer.HandleEvent(ctx, event)) + case "older event time": + event.TimeUS = time.Now().Add(-time.Hour).UnixMicro() + require.NoError(t, fixture.consumer.HandleEvent(ctx, event)) + case "already deleted": + require.NoError(t, fixture.consumer.HandleEvent(ctx, revCommitEvent( + fixture.gate.commenterDID, CommentCollection, "delete", fixture.key, + revision, "", baseTime, nil))) + notificationCount = countRows(t, fixture.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, fixture.uri) + event.Commit.Rev = testkit.TID() + require.Less(t, revision, event.Commit.Rev) + require.NoError(t, fixture.consumer.HandleEvent(ctx, event)) + case "concurrent delete", "concurrent recency loss": + results := make(chan error, 1) + started := false + finished := false + t.Cleanup(func() { + if started && !finished { + commentErasureResult(t, ctx, results, "HandleEvent(update) after fixture rollback") + } + }) + transaction, processID := mentionEditRowTransaction(t, ctx, fixture.gate.db) + if name == "concurrent delete" { + _, err := transaction.ExecContext(ctx, `UPDATE comments SET deleted_at = now() WHERE uri = $1`, fixture.uri) + require.NoError(t, err) + } else { + _, err := transaction.ExecContext(ctx, `UPDATE comments SET indexed_at = $1 WHERE uri = $2`, time.UnixMicro(baseTime).Add(time.Hour), fixture.uri) + require.NoError(t, err) + } + started = true + go func() { results <- fixture.consumer.HandleEvent(ctx, event) }() + commentErasureBlockedByFixture(t, ctx, transaction, processID, "comments") + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(update)") + finished = true + if name == "concurrent recency loss" { + var content string + require.NoError(t, fixture.gate.db.QueryRow(`SELECT content FROM comments WHERE uri = $1`, fixture.uri).Scan(&content)) + assert.Equal(t, contentBefore, content, "zero-row update preserves original content") + } + var storedRevision string + require.NoError(t, fixture.gate.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, fixture.uri).Scan(&storedRevision)) + assert.Equal(t, fixture.revision, storedRevision, "a superseded update rolls back the incoming revision") + default: + t.Fatalf("unhandled nonqualifying update case %q", name) + } + assert.Equal(t, notificationCount, countRows(t, fixture.gate.db, + `SELECT count(*) FROM notifications WHERE record_uri = $1`, fixture.uri), + "nonqualifying update must not change the record's notification count") + assert.Zero(t, mentionEditRows(t, fixture, added, "mention"), "nonqualifying update must not leak an eligible edit-added mention") + }) + } +} + +func TestCommentConsumer_MentionEdit_NotificationFailureRollsBack(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + var beforeContent, beforeRevision string + var beforeFacets sql.NullString + var beforeIndexedAt time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT content, content_facets, indexed_at FROM comments WHERE uri = $1`, fixture.uri).Scan(&beforeContent, &beforeFacets, &beforeIndexedAt)) + require.NoError(t, fixture.gate.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, fixture.uri).Scan(&beforeRevision)) + injected := errors.New("injected edit notification write failure") + failing := &failingCommentNotificationRepository{delegate: postgres.NewNotificationRepository(fixture.gate.db), failure: injected} + fixture.consumer = fixture.gate.commentConsumer(WithCommentNotifications(failing)) + err := fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), added) + assert.ErrorIs(t, err, injected, "edit notification write failure must reach the caller") + if assert.Len(t, failing.intents, 1, "eligible E mention must reach ApplyTx before the injected failure") { + assert.Equal(t, added.did, failing.intents[0].RecipientDID) + assert.Equal(t, notifications.ReasonMention, failing.intents[0].Reason) + } + var afterContent, afterRevision string + var afterFacets sql.NullString + var afterIndexedAt time.Time + require.NoError(t, fixture.gate.db.QueryRow(`SELECT content, content_facets, indexed_at FROM comments WHERE uri = $1`, fixture.uri).Scan(&afterContent, &afterFacets, &afterIndexedAt)) + require.NoError(t, fixture.gate.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, fixture.uri).Scan(&afterRevision)) + assert.Equal(t, beforeContent, afterContent, "failed edit must roll back content") + assert.Equal(t, beforeFacets, afterFacets, "failed edit must roll back facets") + assert.True(t, beforeIndexedAt.Equal(afterIndexedAt), "failed edit must roll back indexed_at") + assert.Equal(t, beforeRevision, afterRevision, "failed edit must roll back its rev claim") + assert.Zero(t, mentionEditRows(t, fixture, added, "mention")) +} + +func TestCommentConsumer_MentionEdit_DeleteFirstWaitsBeforeContent(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + fixture := newCommentErasureFixture(t, ctx, db) + priorEvent, priorURI := fixture.reply() + require.NoError(t, fixture.consumer.HandleEvent(ctx, priorEvent)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, priorURI)) + mentionedID := testkit.UniqueID(t) + mentioned := mentionEditRecipient{did: "did:plc:" + mentionedID + "mentioned", handle: mentionedID + "mentioned.test"} + _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, mentioned.did, mentioned.handle, bridgedTestNativePDS) + require.NoError(t, err) + key := strings.TrimPrefix(priorURI, "at://"+fixture.actorDID+"/"+CommentCollection+"/") + content := "A adds @" + mentioned.handle + record := revCommentRecord(content, fixture.postURI, fixture.postCID, fixture.postURI, fixture.postCID) + record["createdAt"] = fixture.createdAt + record["facets"] = []interface{}{commentMentionFacet(t, content, mentioned.handle, mentioned.did)} + event := revCommitEvent(fixture.actorDID, CommentCollection, "update", key, + testkit.TID(), "bafyreimentionediterasure", time.Now().Add(time.Second).UnixMicro(), record) + require.Less(t, priorEvent.Commit.Rev, event.Commit.Rev, "fixture: the edit must pass the revision gate") + + deleteResults := make(chan error, 1) + consumerResults := make(chan error, 1) + deleteStarted, consumerStarted := false, false + deleteFinished, consumerFinished := false, false + t.Cleanup(func() { + if deleteStarted && !deleteFinished { + commentErasureResult(t, ctx, deleteResults, "Delete(A) after fixture rollback") + } + if consumerStarted && !consumerFinished { + commentErasureResult(t, ctx, consumerResults, "HandleEvent(edit A) after fixture rollback") + } + }) + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, db, fixture.actorDID) + deleteStarted = true + go func() { deleteResults <- postgres.NewUserRepository(db).Delete(ctx, fixture.actorDID) }() + deleteProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "DELETE FROM users") + consumerStarted = true + go func() { consumerResults <- fixture.consumer.HandleEvent(ctx, event) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + var waitingBeforeContent bool + err := transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid NOT IN ($1, $2) + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND NOT EXISTS (SELECT 1 FROM pg_locks other WHERE other.pid = waiter.pid + AND NOT other.granted AND other.locktype IN ('tuple', 'transactionid')) + AND NOT EXISTS (SELECT 1 FROM pg_locks content WHERE content.pid = waiter.pid + AND content.relation IN ('comments'::regclass, 'posts'::regclass)) + )`, deleteProcessID, fixtureProcessID).Scan(&waitingBeforeContent) + return waitingBeforeContent, err + }, testkit.WithDescription("mention edit waits on erasure ShareLock before locking comments or posts")) + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, deleteResults, "Delete(A)") + deleteFinished = true + commentErasureResult(t, ctx, consumerResults, "HandleEvent(edit A)") + consumerFinished = true + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE actor_did = $1 AND reason = 'mention'`, fixture.actorDID), "delete-first edit must not notify anyone as erased A") +} + +func TestCommentConsumer_MentionEdit_ErasedActor(t *testing.T) { + t.Parallel() + fixture := newMentionEditFixture(t) + added := fixture.recipient(t) + fixture.create(t) + _, err := fixture.gate.db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, fixture.gate.commenterDID) + require.NoError(t, err, "fixture: retain the comment row and mark A erased") + require.Equal(t, 1, countRows(t, fixture.gate.db, `SELECT count(*) FROM comments WHERE uri = $1`, fixture.uri)) + require.NoError(t, fixture.update(t, fixture.createdAt, time.Now().Add(time.Second).UnixMicro(), added)) + assert.Zero(t, mentionEditRows(t, fixture, added, "mention"), "erased A must not notify eligible E") +} diff --git a/internal/atproto/jetstream/comment_notification_mentions_test.go b/internal/atproto/jetstream/comment_notification_mentions_test.go new file mode 100644 index 0000000..0737286 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_mentions_test.go @@ -0,0 +1,395 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func commentMentionFacet(t *testing.T, content, handle, did string) interface{} { + t.Helper() + start := strings.Index(content, handle) + require.NotEqual(t, -1, start, "fixture: mentioned handle must appear in the comment content") + return map[string]interface{}{ + "index": map[string]interface{}{"byteStart": start, "byteEnd": start + len(handle)}, + "features": []interface{}{ + map[string]interface{}{"$type": "social.coves.richtext.facet#mention", "did": did}, + }, + } +} + +func requireStoredMentionFacets(t *testing.T, db *sql.DB, commentURI string, mentionedDIDs ...string) { + t.Helper() + var facets sql.NullString + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT content_facets FROM comments WHERE uri = $1`, commentURI).Scan(&facets)) + require.True(t, facets.Valid, "fixture: valid mention facets must survive comment indexing") + for _, did := range mentionedDIDs { + require.Contains(t, facets.String, did, "fixture: the mention feature must be stored") + } +} + +func TestCommentConsumer_MentionOnPostReplyDeduplicatesReplyRecipient(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newNotificationGateFixture(t, bridgedTestNativePDS, bridgedTestNativePDS, false) + post := fixture.posts[0] + otherDID := "did:plc:" + testkit.UniqueID(t) + "mentioned" + otherHandle := testkit.UniqueID(t) + "mentioned.test" + _, err := fixture.db.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + otherDID, otherHandle, bridgedTestNativePDS) + require.NoError(t, err, "index D as an eligible mention recipient") + createdAt := activatedCommentNotificationTime(t, fixture.db, ctx) + + var postAuthorHandle string + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT handle FROM users WHERE did = $1`, post.authorDID).Scan(&postAuthorHandle)) + content := "A replies to @" + postAuthorHandle + " and @" + otherHandle + commentRecord := revCommentRecord(content, post.uri, post.cid, post.uri, post.cid) + commentRecord["createdAt"] = createdAt + commentRecord["facets"] = []interface{}{ + commentMentionFacet(t, content, postAuthorHandle, post.authorDID), + commentMentionFacet(t, content, otherHandle, otherDID), + } + commentKey := testkit.TID() + commentURI := "at://" + fixture.commenterDID + "/" + CommentCollection + "/" + commentKey + commentCID := "bafyreicommentmentionreply" + require.NoError(t, fixture.commentConsumer().HandleEvent(ctx, revCommitEvent( + fixture.commenterDID, CommentCollection, "create", commentKey, testkit.TID(), commentCID, + time.Now().UnixMicro(), commentRecord, + )), "index A's top-level comment mentioning B and D") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "fixture: the comment must be indexed") + requireStoredMentionFacets(t, fixture.db, commentURI, post.authorDID, otherDID) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'postReply' AND record_uri = $2`, post.authorDID, commentURI), + "B receives exactly one postReply") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'mention' AND record_uri = $2`, post.authorDID, commentURI), + "B must not also receive a mention for the same reply") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'mention' AND record_uri = $2`, otherDID, commentURI), + "D must receive exactly one mention for A's comment") + + var actorDID, recordURI, recordCID, rootPostURI string + var subjectURI sql.NullString + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT actor_did, record_uri, record_cid, subject_uri, root_post_uri + FROM notifications WHERE recipient_did = $1 AND reason = 'mention' AND record_uri = $2`, + otherDID, commentURI).Scan(&actorDID, &recordURI, &recordCID, &subjectURI, &rootPostURI)) + require.False(t, subjectURI.Valid, "mention subject_uri must be NULL") + require.Equal(t, post.uri, rootPostURI) + require.Equal(t, fixture.commenterDID, actorDID) + require.Equal(t, commentURI, recordURI) + require.Equal(t, commentCID, recordCID) +} + +func TestCommentConsumer_MentionWithUnsupportedParentCollection(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newNotificationGateFixture(t, bridgedTestNativePDS, bridgedTestNativePDS, false) + post := fixture.posts[0] + createdAt := activatedCommentNotificationTime(t, fixture.db, ctx) + parentURI := "at://" + post.authorDID + "/app.bsky.feed.post/" + testkit.TID() + var handle string + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT handle FROM users WHERE did = $1`, post.authorDID).Scan(&handle)) + content := "A mentions @" + handle + " in this thread" + commentRecord := revCommentRecord(content, post.uri, post.cid, parentURI, "bafyreicommentmentionparent") + commentRecord["createdAt"] = createdAt + commentRecord["facets"] = []interface{}{commentMentionFacet(t, content, handle, post.authorDID)} + commentKey := testkit.TID() + commentURI := "at://" + fixture.commenterDID + "/" + CommentCollection + "/" + commentKey + commentCID := "bafyreicommentmentionunsupported" + require.NoError(t, fixture.commentConsumer().HandleEvent(ctx, revCommitEvent( + fixture.commenterDID, CommentCollection, "create", commentKey, testkit.TID(), commentCID, + time.Now().UnixMicro(), commentRecord, + )), "index A's comment with an unsupported parent collection") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "fixture: the comment must still be indexed") + requireStoredMentionFacets(t, fixture.db, commentURI, post.authorDID) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'mention' AND actor_did = $2 AND record_uri = $3 + AND record_cid = $4 AND subject_uri IS NULL AND root_post_uri = $5`, + post.authorDID, fixture.commenterDID, commentURI, commentCID, post.uri), + "B must receive exactly one mention even when the parent collection is unsupported") +} + +// A root that parses but is not a post cannot become a notification's +// root_post_uri: the comment indexes with its facets, and nobody is notified. +func TestCommentConsumer_MentionWithNonPostRootNotifiesNobody(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newNotificationGateFixture(t, bridgedTestNativePDS, bridgedTestNativePDS, false) + post := fixture.posts[0] + createdAt := activatedCommentNotificationTime(t, fixture.db, ctx) + rootURI := "at://" + post.authorDID + "/app.bsky.feed.post/" + testkit.TID() + const rootCID = "bafyreicommentmentionnonpostroot" + var handle string + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT handle FROM users WHERE did = $1`, post.authorDID).Scan(&handle)) + content := "A mentions @" + handle + " under a Bluesky post" + commentRecord := revCommentRecord(content, rootURI, rootCID, rootURI, rootCID) + commentRecord["createdAt"] = createdAt + commentRecord["facets"] = []interface{}{commentMentionFacet(t, content, handle, post.authorDID)} + commentKey := testkit.TID() + commentURI := "at://" + fixture.commenterDID + "/" + CommentCollection + "/" + commentKey + require.NoError(t, fixture.commentConsumer().HandleEvent(ctx, revCommitEvent( + fixture.commenterDID, CommentCollection, "create", commentKey, testkit.TID(), "bafyreicommentmentionnonpost", + time.Now().UnixMicro(), commentRecord, + )), "index A's comment whose root is a Bluesky post") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "fixture: the comment must still be indexed") + requireStoredMentionFacets(t, fixture.db, commentURI, post.authorDID) + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "indexed, eligible B must not get a mention whose root_post_uri names a Bluesky post") +} + +func TestCommentConsumer_MentionDuplicateDelivery(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newNotificationGateFixture(t, bridgedTestNativePDS, bridgedTestNativePDS, false) + post := fixture.posts[0] + mentionedDID := "did:plc:" + testkit.UniqueID(t) + "mentioned" + mentionedHandle := testkit.UniqueID(t) + "mentioned.test" + _, err := fixture.db.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + mentionedDID, mentionedHandle, bridgedTestNativePDS) + require.NoError(t, err, "index D as an eligible mention recipient") + var postAuthorHandle string + require.NoError(t, fixture.db.QueryRowContext(ctx, + `SELECT handle FROM users WHERE did = $1`, post.authorDID).Scan(&postAuthorHandle)) + content := "A replies to @" + postAuthorHandle + " and @" + mentionedHandle + commentRecord := revCommentRecord(content, post.uri, post.cid, post.uri, post.cid) + commentRecord["createdAt"] = activatedCommentNotificationTime(t, fixture.db, ctx) + commentRecord["facets"] = []interface{}{ + commentMentionFacet(t, content, postAuthorHandle, post.authorDID), + commentMentionFacet(t, content, mentionedHandle, mentionedDID), + } + commentKey := testkit.TID() + commentURI := "at://" + fixture.commenterDID + "/" + CommentCollection + "/" + commentKey + commentCID := "bafyreicommentmentionduplicate" + event := revCommitEvent(fixture.commenterDID, CommentCollection, "create", commentKey, + testkit.TID(), commentCID, time.Now().UnixMicro(), commentRecord) + consumer := fixture.commentConsumer() + require.NoError(t, consumer.HandleEvent(ctx, event), "index A's comment mentioning B and D") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI)) + requireStoredMentionFacets(t, fixture.db, commentURI, post.authorDID, mentionedDID) + require.NoError(t, consumer.HandleEvent(ctx, event), "the identical create event must be idempotent") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'postReply' AND record_uri = $2`, post.authorDID, commentURI), + "B must have exactly one postReply after duplicate delivery") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'mention' AND record_uri = $2`, post.authorDID, commentURI), + "B's reply notification must not also be a mention") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'mention' AND record_uri = $2`, mentionedDID, commentURI), + "D must have exactly one mention after duplicate delivery") + require.Equal(t, 2, countRows(t, fixture.db, + `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "a duplicate delivery must leave exactly B's reply and D's mention") +} + +func TestCommentConsumer_MentionStaleRevReplay(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newNotificationGateFixture(t, bridgedTestNativePDS, bridgedTestNativePDS, false) + post := fixture.posts[0] + mentionedDID := "did:plc:" + testkit.UniqueID(t) + "mentioned" + mentionedHandle := testkit.UniqueID(t) + "mentioned.test" + staleMentionDID := "did:plc:" + testkit.UniqueID(t) + "stale" + staleMentionHandle := testkit.UniqueID(t) + "stale.test" + for _, recipient := range []struct{ did, handle string }{ + {mentionedDID, mentionedHandle}, + {staleMentionDID, staleMentionHandle}, + } { + _, err := fixture.db.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + recipient.did, recipient.handle, bridgedTestNativePDS) + require.NoError(t, err, "index D and E so a stale mention would be deliverable") + } + var postAuthorHandle string + require.NoError(t, fixture.db.QueryRowContext(ctx, + `SELECT handle FROM users WHERE did = $1`, post.authorDID).Scan(&postAuthorHandle)) + createdAt := activatedCommentNotificationTime(t, fixture.db, ctx) + content := "A replies to @" + postAuthorHandle + " and @" + mentionedHandle + commentRecord := revCommentRecord(content, post.uri, post.cid, post.uri, post.cid) + commentRecord["createdAt"] = createdAt + commentRecord["facets"] = []interface{}{ + commentMentionFacet(t, content, postAuthorHandle, post.authorDID), + commentMentionFacet(t, content, mentionedHandle, mentionedDID), + } + commentKey := testkit.TID() + commentURI := "at://" + fixture.commenterDID + "/" + CommentCollection + "/" + commentKey + olderRev, newerRev := testkit.TID(), testkit.TID() + require.Less(t, olderRev, newerRev, "fixture: repo revisions must be lexicographically ordered") + const newerCID = "bafyreicommentmentionnewer" + consumer := fixture.commentConsumer() + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + fixture.commenterDID, CommentCollection, "create", commentKey, newerRev, newerCID, + time.Now().UnixMicro(), commentRecord, + )), "index A's newer comment mentioning B and D") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI)) + requireStoredMentionFacets(t, fixture.db, commentURI, post.authorDID, mentionedDID) + + type notificationSnapshot struct { + recordCID string + sortAt time.Time + } + readNotifications := func() map[string]notificationSnapshot { + t.Helper() + rows, err := fixture.db.QueryContext(ctx, ` + SELECT recipient_did, reason, record_cid, sort_at FROM notifications WHERE record_uri = $1`, commentURI) + require.NoError(t, err) + defer rows.Close() + got := make(map[string]notificationSnapshot) + for rows.Next() { + var recipientDID, reason string + var snapshot notificationSnapshot + require.NoError(t, rows.Scan(&recipientDID, &reason, &snapshot.recordCID, &snapshot.sortAt)) + got[recipientDID+"/"+reason] = snapshot + } + require.NoError(t, rows.Err()) + return got + } + before := readNotifications() + var storedRev string + require.NoError(t, fixture.db.QueryRowContext(ctx, + `SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, commentURI).Scan(&storedRev)) + require.Equal(t, newerRev, storedRev) + var firstCommentCID string + require.NoError(t, fixture.db.QueryRowContext(ctx, + `SELECT cid FROM comments WHERE uri = $1`, commentURI).Scan(&firstCommentCID)) + require.Equal(t, newerCID, firstCommentCID) + + staleContent := content + " and @" + staleMentionHandle + staleRecord := revCommentRecord(staleContent, post.uri, post.cid, post.uri, post.cid) + staleRecord["createdAt"] = createdAt + staleRecord["facets"] = []interface{}{ + commentMentionFacet(t, staleContent, postAuthorHandle, post.authorDID), + commentMentionFacet(t, staleContent, mentionedHandle, mentionedDID), + commentMentionFacet(t, staleContent, staleMentionHandle, staleMentionDID), + } + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + fixture.commenterDID, CommentCollection, "create", commentKey, olderRev, "bafyreicommentmentionolder", + time.Now().Add(time.Minute).UnixMicro(), staleRecord, + )), "a stale create with an additional mention must be ignored") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND record_uri = $2`, staleMentionDID, commentURI), + "E is indexed and eligible, but only a stale revision mentions E") + after := readNotifications() + require.Equal(t, before, after, "B's and D's notification CIDs and sort_at must survive the stale replay") + var replayCommentCID, replayRev string + require.NoError(t, fixture.db.QueryRowContext(ctx, + `SELECT cid FROM comments WHERE uri = $1`, commentURI).Scan(&replayCommentCID)) + require.Equal(t, firstCommentCID, replayCommentCID, "the stale CID must not replace the stored comment") + require.NoError(t, fixture.db.QueryRowContext(ctx, + `SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, commentURI).Scan(&replayRev)) + require.Equal(t, storedRev, replayRev, "the older revision must not replace R2") + require.Len(t, after, 2, "the newer create must leave exactly B's reply and D's mention") + require.Contains(t, after, post.authorDID+"/postReply") + require.Contains(t, after, mentionedDID+"/mention") + require.Equal(t, newerCID, after[post.authorDID+"/postReply"].recordCID) + require.Equal(t, newerCID, after[mentionedDID+"/mention"].recordCID) +} + +func unsupportedParentMentionEvent(t *testing.T, ctx context.Context, db *sql.DB, fixture commentErasureFixture) (*JetstreamEvent, string) { + t.Helper() + var recipientHandle string + require.NoError(t, db.QueryRowContext(ctx, + `SELECT handle FROM users WHERE did = $1`, fixture.recipientDID).Scan(&recipientHandle), + "fixture: B must be indexed before the mention") + content := "A mentions @" + recipientHandle + " in another collection" + parentURI := "at://" + fixture.recipientDID + "/app.bsky.feed.post/" + testkit.TID() + record := revCommentRecord(content, fixture.postURI, fixture.postCID, parentURI, "bafyreicommentmentionparent") + record["createdAt"] = fixture.createdAt + record["facets"] = []interface{}{commentMentionFacet(t, content, recipientHandle, fixture.recipientDID)} + key := testkit.TID() + return revCommitEvent(fixture.actorDID, CommentCollection, "create", key, + testkit.TID(), "bafyreicommentmentionunsupportedbranch", time.Now().UnixMicro(), record), + "at://" + fixture.actorDID + "/" + CommentCollection + "/" + key +} + +func TestCommentConsumer_MentionUnsupportedParentNotificationFailureRollsBack(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newCommentErasureFixture(t, ctx, db) + event, commentURI := unsupportedParentMentionEvent(t, ctx, db, fixture) + injectedError := errors.New("injected unsupported-parent mention write failure") + failingRepository := &failingCommentNotificationRepository{ + delegate: postgres.NewNotificationRepository(db), + failure: injectedError, + } + consumer := NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(failingRepository)) + err := consumer.HandleEvent(ctx, event) + assert.ErrorIs(t, err, injectedError, "a failed mention write on the early-commit branch must fail the delivery") + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "the comment must roll back with the failed mention") + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM jetstream_record_revs WHERE record_uri = $1`, commentURI), + "the failed delivery must not claim the revision") + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI)) + if assert.Len(t, failingRepository.intents, 1, "fixture: the eligible B mention must reach ApplyTx") { + assert.Equal(t, fixture.recipientDID, failingRepository.intents[0].RecipientDID) + assert.Equal(t, notifications.ReasonMention, failingRepository.intents[0].Reason) + } +} + +func TestCommentConsumer_MentionUnsupportedParentErasure_DeleteFirstWaitsBeforeContent(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + fixture := newCommentErasureFixture(t, ctx, db) + priorEvent, priorURI := fixture.reply() + require.NoError(t, fixture.consumer.HandleEvent(ctx, priorEvent)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, priorURI), + "fixture: A has content before deletion starts") + event, commentURI := unsupportedParentMentionEvent(t, ctx, db, fixture) + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, db, fixture.actorDID) + deleteResults := make(chan error, 1) + go func() { deleteResults <- postgres.NewUserRepository(db).Delete(ctx, fixture.actorDID) }() + deleteProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "DELETE FROM users") + + consumerResults := make(chan error, 1) + go func() { consumerResults <- fixture.consumer.HandleEvent(ctx, event) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + var waitingBeforeContent bool + err := transaction.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid NOT IN ($1, $2) + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND NOT EXISTS (SELECT 1 FROM pg_locks other + WHERE other.pid = waiter.pid AND NOT other.granted + AND other.locktype IN ('tuple', 'transactionid')) + AND NOT EXISTS (SELECT 1 FROM pg_locks content + WHERE content.pid = waiter.pid + AND content.relation IN ('comments'::regclass, 'posts'::regclass)) + )`, deleteProcessID, fixtureProcessID).Scan(&waitingBeforeContent) + return waitingBeforeContent, err + }, testkit.WithDescription("unsupported-parent comment waiting for Delete's erasure lock before touching comments or posts")) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, deleteResults, "Delete(A)") + commentErasureResult(t, ctx, consumerResults, "HandleEvent(A unsupported parent)") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE actor_did = $1`, fixture.actorDID), + "Delete-first interleaving must leave no notifications from erased A") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'mention' AND record_uri = $2`, fixture.recipientDID, commentURI), + "the unsupported-parent comment must not mention B after A is erased") +} diff --git a/internal/atproto/jetstream/comment_notification_resurrection_erasure_test.go b/internal/atproto/jetstream/comment_notification_resurrection_erasure_test.go new file mode 100644 index 0000000..13efe4a --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_resurrection_erasure_test.go @@ -0,0 +1,175 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestCommentConsumer_ResurrectionErasureFirstWaitsOnAdvisoryLockBeforeCommentRow(t *testing.T) { + t.Parallel() + for _, parent := range []string{"same_parent", "different_parent"} { + t.Run(parent, func(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + fixture := newCommentErasureFixture(t, ctx, db) + originalEvent, commentURI := fixture.reply() + require.NoError(t, fixture.consumer.HandleEvent(ctx, originalEvent)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1 AND deleted_at IS NULL`, commentURI)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND reason = 'postReply' AND recipient_did = $2`, commentURI, fixture.recipientDID)) + deleteRevision := testkit.TID() + require.Less(t, originalEvent.Commit.Rev, deleteRevision) + require.NoError(t, fixture.consumer.HandleEvent(ctx, revCommitEvent( + fixture.actorDID, CommentCollection, "delete", originalEvent.Commit.RKey, + deleteRevision, "", time.Now().UnixMicro(), nil))) + var deletedAt sql.NullTime + require.NoError(t, db.QueryRowContext(ctx, `SELECT deleted_at FROM comments WHERE uri = $1`, commentURI).Scan(&deletedAt)) + require.True(t, deletedAt.Valid, "Y must be soft-deleted before the re-create") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "author delete keeps the reply pending erasure") + + parentURI, parentCID := fixture.postURI, fixture.postCID + if parent == "different_parent" { + parentID := testkit.UniqueID(t) + parentDID := "did:plc:" + parentID + "parent" + insertBridgedUserOnPDS(t, db, parentDID, parentID+"parent.test", bridgedTestNativePDS) + parentKey := testkit.TID() + parentURI = "at://" + parentDID + "/" + CommentCollection + "/" + parentKey + parentCID = "bafyresurrectionerasureparent" + parentRecord := revCommentRecord("C replies to B's post", fixture.postURI, fixture.postCID, + fixture.postURI, fixture.postCID) + parentRecord["createdAt"] = fixture.createdAt + require.NoError(t, fixture.consumer.HandleEvent(ctx, revCommitEvent( + parentDID, CommentCollection, "create", parentKey, testkit.TID(), parentCID, + time.Now().UnixMicro(), parentRecord))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, parentURI)) + } + recreateRevision := testkit.TID() + require.Less(t, deleteRevision, recreateRevision) + const recreatedCID = "bafyresurrectionerasurecreated" + record := revCommentRecord("A re-creates Y after account erasure", fixture.postURI, fixture.postCID, parentURI, parentCID) + record["createdAt"] = fixture.createdAt + recreateEvent := revCommitEvent(fixture.actorDID, CommentCollection, "create", + originalEvent.Commit.RKey, recreateRevision, recreatedCID, time.Now().UnixMicro(), record) + + results := make(chan error, 1) + started, finished := false, false + // Registered before the transaction: cleanup first releases its locks, + // then this drains the blocked consumer, then cancels its context. + t.Cleanup(func() { + if started && !finished { + commentErasureResult(t, ctx, results, "HandleEvent(resurrection) after fixture rollback") + } + }) + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, db, fixture.actorDID) + _, err := transaction.ExecContext(ctx, + "SELECT pg_advisory_xact_lock("+postgres.ErasureLockKeySQL+")", fixture.actorDID) + require.NoError(t, err) + _, err = transaction.ExecContext(ctx, + `INSERT INTO deleted_accounts (did, deleted_at) VALUES ($1, NOW())`, fixture.actorDID) + require.NoError(t, err) + removed, err := transaction.ExecContext(ctx, `DELETE FROM comments WHERE commenter_did = $1`, fixture.actorDID) + require.NoError(t, err) + removedRows, err := removed.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, removedRows, "erasure must delete and lock the soft-deleted Y") + _, err = transaction.ExecContext(ctx, `DELETE FROM notifications WHERE actor_did = $1`, fixture.actorDID) + require.NoError(t, err) + + started = true + go func() { results <- fixture.consumer.HandleEvent(ctx, recreateEvent) }() + consumerProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, + "pg_advisory_xact_lock_shared") + var waitingOnlyForErasure bool + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE waiter.pid = $1 AND holder.pid = $2 + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND NOT EXISTS (SELECT 1 FROM pg_locks other WHERE other.pid = $1 + AND NOT other.granted AND other.locktype IN ('tuple', 'transactionid')) + AND NOT EXISTS (SELECT 1 FROM pg_locks content WHERE content.pid = $1 + AND content.relation = 'comments'::regclass) + )`, consumerProcessID, fixtureProcessID).Scan(&waitingOnlyForErasure)) + require.True(t, waitingOnlyForErasure, + "resurrection must wait for erasure's advisory lock without holding or waiting on Y's row") + + require.NoError(t, transaction.Commit(), "finish fixture account erasure") + commentErasureResult(t, ctx, results, "HandleEvent(resurrection) after account erasure") + finished = true + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, fixture.actorDID)) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "erasure hard-deleted Y, so the re-create takes the insert path; erased A must not generate notifications there") + // Known content-indexing gap: erasure removed the old row, but the + // re-create indexes a new live comment for erased A without notifying. + var storedCID, storedParentURI string + var finalDeletedAt sql.NullTime + require.NoError(t, db.QueryRowContext(ctx, + `SELECT cid, parent_uri, deleted_at FROM comments WHERE uri = $1`, commentURI). + Scan(&storedCID, &storedParentURI, &finalDeletedAt)) + require.Equal(t, recreatedCID, storedCID, "the accepted indexing gap writes the new CID") + require.Equal(t, parentURI, storedParentURI) + require.False(t, finalDeletedAt.Valid, "the erased author's new row is live but has no notifications") + }) + } +} + +// Erasure normally hard-deletes A's comments, but the consumer still indexes an +// erased actor's events. A soft-deleted Y that survives erasure reaches the +// same-parent resurrection branch with the actor already erased. +func TestCommentConsumer_SameParentResurrectionByErasedActorWritesNoNotifications(t *testing.T) { + t.Parallel() + ctx := context.Background() + fixture := newMentionEditFixture(t) + postAuthor := fixture.postAuthor(t) + mentioned := fixture.recipient(t) + fixture.create(t) + require.Equal(t, 1, mentionEditRows(t, fixture, postAuthor, "postReply"), "fixture: the original create must notify B") + + deleteRevision := testkit.TID() + require.Less(t, fixture.revision, deleteRevision) + require.NoError(t, fixture.consumer.HandleEvent(ctx, revCommitEvent( + fixture.gate.commenterDID, CommentCollection, "delete", fixture.key, deleteRevision, "", + time.Now().UnixMicro(), nil)), "A deletes Y") + var commentID int64 + var deletedAt sql.NullTime + require.NoError(t, fixture.gate.db.QueryRowContext(ctx, `SELECT id, deleted_at FROM comments WHERE uri = $1`, + fixture.uri).Scan(&commentID, &deletedAt)) + require.True(t, deletedAt.Valid, "fixture: author delete must keep Y as a soft-deleted row") + keptRows := notificationRowsForRecordOrSubject(t, fixture.gate.db, fixture.uri) + require.Len(t, keptRows, 1, "fixture: author delete must keep Y's reply notification") + _, err := fixture.gate.db.ExecContext(ctx, `INSERT INTO deleted_accounts (did) VALUES ($1)`, fixture.gate.commenterDID) + require.NoError(t, err, "fixture: mark A erased while keeping the soft-deleted comment row") + + recreateRevision := testkit.TID() + require.Less(t, deleteRevision, recreateRevision) + const recreatedCID = "bafyreisameparenterasedrecreated" + require.NoError(t, fixture.consumer.HandleEvent(ctx, revCommitEvent( + fixture.gate.commenterDID, CommentCollection, "create", fixture.key, recreateRevision, recreatedCID, + time.Now().UnixMicro(), fixture.record(t, fixture.createdAt, mentioned))), + "erased A re-creates Y on the same parent and mentions E") + var resurrectedID int64 + var resurrectedCID string + var resurrectedDeletedAt sql.NullTime + require.NoError(t, fixture.gate.db.QueryRowContext(ctx, `SELECT id, cid, deleted_at FROM comments WHERE uri = $1`, + fixture.uri).Scan(&resurrectedID, &resurrectedCID, &resurrectedDeletedAt)) + require.Equal(t, commentID, resurrectedID, "the re-create must resurrect the kept row, not insert a new one") + require.Equal(t, recreatedCID, resurrectedCID, "the resurrection must apply the new CID") + require.False(t, resurrectedDeletedAt.Valid, "the resurrection must clear the delete") + require.Equal(t, keptRows, notificationRowsForRecordOrSubject(t, fixture.gate.db, fixture.uri), + "erased A's same-parent resurrection must keep B's row without notifying E") +} diff --git a/internal/atproto/jetstream/comment_notification_resurrection_repair_test.go b/internal/atproto/jetstream/comment_notification_resurrection_repair_test.go new file mode 100644 index 0000000..98dd20e --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_resurrection_repair_test.go @@ -0,0 +1,309 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type commentNotificationRepairFixture struct { + commentAuthorDeleteFixture + key, uri, firstParentURI, firstParentCID, firstCID, deleteRevision string + secondPostURI, secondPostCID, secondParentURI, secondParentCID string + newParentDID, newParentHandle, mentionedDID, mentionedHandle string + otherRecordURI string + before, otherRecordBefore []notificationRowSnapshot +} + +func newCommentNotificationRepairFixture(t *testing.T, topLevel, sameRoot bool) commentNotificationRepairFixture { + t.Helper() + f := commentNotificationRepairFixture{commentAuthorDeleteFixture: newCommentAuthorDeleteFixture(t)} + f.newParentDID, f.newParentHandle = f.mentionRecipient(t) + f.mentionedDID, f.mentionedHandle = f.mentionRecipient(t) + f.firstParentURI, f.firstParentCID = f.postURI, f.postCID + if !topLevel { + f.firstParentURI, f.firstParentCID = f.parent(t, revTestAuthor) + } + f.secondPostURI, f.secondPostCID = f.postURI, f.postCID + if !sameRoot && !topLevel { + key := testkit.TID() + f.secondPostURI = pv2URI(revTestAuthor, key) + f.secondPostCID = "bafyrepairsecondpost" + _, err := f.db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, $2, $3, $4, $5, 'second root', NOW())`, + f.secondPostURI, f.secondPostCID, key, revTestAuthor, revTestCommunity) + require.NoError(t, err) + } + if topLevel { + key := testkit.TID() + f.secondPostURI = "at://" + revTestCommunity + "/social.coves.community.post/" + key + f.secondPostCID = "bafyrepairlegacypost" + _, err := f.db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, $2, $3, $4, $5, 'legacy second root', NOW())`, + f.secondPostURI, f.secondPostCID, key, f.newParentDID, revTestCommunity) + require.NoError(t, err) + f.secondParentURI, f.secondParentCID = f.secondPostURI, f.secondPostCID + } else { + key := testkit.TID() + f.secondParentURI = "at://" + f.newParentDID + "/" + CommentCollection + "/" + key + f.secondParentCID = "bafyrepairsecondparent" + record := revCommentRecord("C's comment X2", f.secondPostURI, f.secondPostCID, f.secondPostURI, f.secondPostCID) + record["createdAt"] = f.createdAt + require.NoError(t, f.consumer.HandleEvent(context.Background(), revCommitEvent( + f.newParentDID, CommentCollection, "create", key, testkit.TID(), f.secondParentCID, + time.Now().UnixMicro(), record)), "index C's second parent") + require.Equal(t, 1, countRows(t, f.db, `SELECT count(*) FROM comments WHERE uri = $1`, f.secondParentURI)) + } + f.otherRecordURI = f.createReply(t, testkit.TID(), testkit.TID(), "bafyrepairotherrecord", + f.record(t, f.postURI, f.postCID, f.firstParentURI, f.firstParentCID)) + f.otherRecordBefore = notificationRowsForRecordOrSubject(t, f.db, f.otherRecordURI) + require.Equal(t, 1, countRows(t, f.db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, f.otherRecordURI, f.newParentDID), + "fixture: C holds a mention from another record") + f.key = testkit.TID() + f.firstCID = "bafyrepairoriginal" + firstRevision := testkit.TID() + f.uri = f.createReply(t, f.key, firstRevision, f.firstCID, + f.record(t, f.postURI, f.postCID, f.firstParentURI, f.firstParentCID)) + f.before = notificationRowsForRecordOrSubject(t, f.db, f.uri) + require.Len(t, f.before, 3, "fixture: B's reply plus D's and C's mentions") + f.deleteRevision = testkit.TID() + require.Less(t, firstRevision, f.deleteRevision) + require.NoError(t, f.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "delete", f.key, f.deleteRevision, "", time.Now().UnixMicro(), nil))) + return f +} + +func (f commentNotificationRepairFixture) record(t *testing.T, rootURI, rootCID, parentURI, parentCID string) map[string]interface{} { + t.Helper() + content := "A mentions @" + f.mentionedHandle + " @" + f.newParentHandle + record := revCommentRecord(content, rootURI, rootCID, parentURI, parentCID) + record["createdAt"] = f.createdAt + record["facets"] = []interface{}{ + commentMentionFacet(t, content, f.mentionedHandle, f.mentionedDID), + commentMentionFacet(t, content, f.newParentHandle, f.newParentDID), + } + return record +} + +func (f commentNotificationRepairFixture) recreatedEvent(t *testing.T, parentURI, parentCID string) *JetstreamEvent { + t.Helper() + return f.recreatedEventForRecord(t, f.record(t, f.secondPostURI, f.secondPostCID, parentURI, parentCID)) +} + +func (f commentNotificationRepairFixture) recreatedEventForRecord(t *testing.T, record map[string]interface{}) *JetstreamEvent { + t.Helper() + revision := testkit.TID() + require.Less(t, f.deleteRevision, revision) + return revCommitEvent(revTestCommenter, CommentCollection, "create", f.key, revision, + "bafyrepairrecreated", time.Now().UnixMicro(), record) +} + +func repairSnapshotForRecipient(t *testing.T, rows []notificationRowSnapshot, recipient string) notificationRowSnapshot { + t.Helper() + for _, row := range rows { + if row.recipient == recipient { + return row + } + } + t.Fatalf("fixture: no original notification for %s", recipient) + return notificationRowSnapshot{} +} + +func TestCommentConsumer_DifferentParentResurrectionRepairsKeptRows(t *testing.T) { + t.Parallel() + for _, scenario := range []struct { + name string + topLevel, sameRoot, erased, unsupported, missingLegacyPost bool + backdated bool + }{ + {name: "different root"}, + {name: "same root", sameRoot: true}, + {name: "erased author", erased: true}, + {name: "backdated createdAt", backdated: true}, + {name: "legacy top-level", topLevel: true}, + {name: "missing legacy top-level post", topLevel: true, missingLegacyPost: true}, + {name: "unsupported parent", unsupported: true}, + } { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + f := newCommentNotificationRepairFixture(t, scenario.topLevel, scenario.sameRoot) + if scenario.missingLegacyPost { + _, err := f.db.Exec(`DELETE FROM posts WHERE uri = $1`, f.secondPostURI) + require.NoError(t, err) + require.Zero(t, countRows(t, f.db, `SELECT count(*) FROM posts WHERE uri = $1`, f.secondPostURI), + "fixture: the legacy post must be absent before Y is re-created") + } + if scenario.erased { + _, err := f.db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, revTestCommenter) + require.NoError(t, err) + } + parentURI, parentCID := f.secondParentURI, f.secondParentCID + if scenario.unsupported { + parentURI = "at://" + revTestAuthor + "/app.bsky.feed.post/" + testkit.TID() + parentCID = "bafyrepairunsupported" + } + record := f.record(t, f.secondPostURI, f.secondPostCID, parentURI, parentCID) + if scenario.backdated { + // Older than the freshness window, so fan-out writes no reply for C. + record["createdAt"] = time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339Nano) + } + require.NoError(t, f.consumer.HandleEvent(context.Background(), f.recreatedEventForRecord(t, record))) + require.Equal(t, f.otherRecordBefore, notificationRowsForRecordOrSubject(t, f.db, f.otherRecordURI), + "the repair touches only the resurrected record's rows") + if scenario.missingLegacyPost { + var deletedAt sql.NullTime + var storedParentURI, storedRootURI, storedCID string + require.NoError(t, f.db.QueryRow(`SELECT deleted_at, parent_uri, root_uri, cid FROM comments WHERE uri = $1`, f.uri). + Scan(&deletedAt, &storedParentURI, &storedRootURI, &storedCID)) + require.False(t, deletedAt.Valid, "Y must be resurrected despite the missing legacy post") + require.Equal(t, f.secondPostURI, storedParentURI) + require.Equal(t, f.secondPostURI, storedRootURI) + require.Equal(t, "bafyrepairrecreated", storedCID) + } + rows := notificationRowsForRecordOrSubject(t, f.db, f.uri) + require.Zero(t, countRows(t, f.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, f.uri, revTestAuthor), + "B's reply to the old parent must be removed") + originalD := repairSnapshotForRecipient(t, f.before, f.mentionedDID) + originalD.rootPostURI = f.secondPostURI + require.Contains(t, rows, originalD, "D keeps the original id, CID, subject and sort time at the new root") + var cRows []notificationRowSnapshot + for _, row := range rows { + if row.recipient == f.newParentDID { + cRows = append(cRows, row) + } + } + if scenario.erased || scenario.backdated || scenario.unsupported || scenario.missingLegacyPost { + originalC := repairSnapshotForRecipient(t, f.before, f.newParentDID) + originalC.rootPostURI = f.secondPostURI + require.ElementsMatch(t, []notificationRowSnapshot{originalD, originalC}, rows, + "with no reply row written for C, B's old reply is removed and both mentions stay at the new root") + if scenario.backdated { + // A second deletion and a fresh same-parent re-create should replace + // C's kept mention with one reply, not leave both rows behind. + backdatedRevision := testkit.TID() + require.Less(t, f.deleteRevision, backdatedRevision) + // The first re-create's revision is stored by the consumer. + var indexedRevision string + require.NoError(t, f.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, f.uri).Scan(&indexedRevision)) + require.Less(t, indexedRevision, backdatedRevision) + require.NoError(t, f.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "delete", f.key, backdatedRevision, "", time.Now().UnixMicro(), nil))) + freshRevision := testkit.TID() + require.Less(t, backdatedRevision, freshRevision) + fresh := f.record(t, f.secondPostURI, f.secondPostCID, f.secondParentURI, f.secondParentCID) + fresh["createdAt"] = activatedCommentNotificationTime(t, f.db, context.Background()) + require.NoError(t, f.consumer.HandleEvent(context.Background(), revCommitEvent( + revTestCommenter, CommentCollection, "create", f.key, freshRevision, + "bafyrepairfreshsameparent", time.Now().UnixMicro(), fresh))) + var reasons []string + for _, row := range notificationRowsForRecordOrSubject(t, f.db, f.uri) { + if row.recipient == f.newParentDID { + reasons = append(reasons, row.reason) + } + } + require.Equal(t, []string{"commentReply"}, reasons, + "C must hold one reply, not both the kept mention and the fresh reply") + } + return + } + require.Len(t, cRows, 1, "C receives exactly one reply rather than a stale mention") + reason := "commentReply" + if scenario.topLevel { + reason = "postReply" + } + require.Equal(t, reason, cRows[0].reason) + require.Equal(t, sql.NullString{String: parentURI, Valid: true}, cRows[0].subjectURI) + require.Equal(t, sql.NullString{String: "bafyrepairrecreated", Valid: true}, cRows[0].recordCID) + require.Equal(t, f.secondPostURI, cRows[0].rootPostURI) + require.NotZero(t, cRows[0].id) + require.Len(t, rows, 2, "only D's kept mention and C's new reply remain") + }) + } +} + +func TestCommentConsumer_ResurrectionWithoutResolvedReplyGivesFormerReplyRecipientOneRow(t *testing.T) { + t.Parallel() + // Y replied directly to B's post P1. The re-create keeps parent P1 but names + // another post as root, which resolves no reply recipient, and mentions B. + f := newCommentNotificationRepairFixture(t, true, false) + content := "A mentions @revauthor.test" + record := revCommentRecord(content, f.secondPostURI, f.secondPostCID, f.postURI, f.postCID) + record["createdAt"] = f.createdAt + record["facets"] = []interface{}{commentMentionFacet(t, content, "revauthor.test", revTestAuthor)} + require.NoError(t, f.consumer.HandleEvent(context.Background(), f.recreatedEventForRecord(t, record))) + rows, err := f.db.Query(`SELECT reason FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, f.uri, revTestAuthor) + require.NoError(t, err) + defer rows.Close() + var reasons []string + for rows.Next() { + var reason string + require.NoError(t, rows.Scan(&reason)) + reasons = append(reasons, reason) + } + require.NoError(t, rows.Err()) + require.Equal(t, []string{"mention"}, reasons, + "B's stale reply is removed when no reply resolves, so the record gives B one notification") +} + +func TestCommentConsumer_ResurrectionUnderNonPostRootKeepsKeptRowsRoot(t *testing.T) { + t.Parallel() + f := newCommentNotificationRepairFixture(t, false, false) + nonPostRoot := "at://" + revTestAuthor + "/app.bsky.feed.post/" + testkit.TID() + record := f.record(t, nonPostRoot, "bafyrepairnonpostroot", f.secondParentURI, f.secondParentCID) + require.NoError(t, f.consumer.HandleEvent(context.Background(), f.recreatedEventForRecord(t, record))) + originalD := repairSnapshotForRecipient(t, f.before, f.mentionedDID) + originalC := repairSnapshotForRecipient(t, f.before, f.newParentDID) + require.ElementsMatch(t, []notificationRowSnapshot{originalD, originalC}, notificationRowsForRecordOrSubject(t, f.db, f.uri), + "B's old reply is removed and the kept mentions keep their post root rather than the non-post root") +} + +type failingCommentNotificationRepairRepository struct { + notifications.Repository + failure error +} + +func (repository *failingCommentNotificationRepairRepository) RepairResurrectedCommentNotificationsTx( + ctx context.Context, tx *sql.Tx, recordURI, replySubjectURI, rootPostURI string, +) error { + if err := repository.Repository.RepairResurrectedCommentNotificationsTx( + ctx, tx, recordURI, replySubjectURI, rootPostURI); err != nil { + return err + } + return repository.failure +} + +func TestCommentConsumer_DifferentParentResurrectionRepairFailureRollsBack(t *testing.T) { + t.Parallel() + f := newCommentNotificationRepairFixture(t, false, false) + before := notificationRowsForRecordOrSubject(t, f.db, f.uri) + var storedRevision string + require.NoError(t, f.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, f.uri).Scan(&storedRevision)) + require.Equal(t, f.deleteRevision, storedRevision) + injected := errors.New("injected resurrection repair failure") + consumer := NewCommentEventConsumer(postgres.NewCommentRepository(f.db), f.db, + WithCommentNotifications(&failingCommentNotificationRepairRepository{ + Repository: postgres.NewNotificationRepository(f.db), failure: injected, + })) + err := consumer.HandleEvent(context.Background(), f.recreatedEvent(t, f.secondParentURI, f.secondParentCID)) + require.ErrorIs(t, err, injected, "repair failure must abort the resurrection transaction") + var deletedAt sql.NullTime + var parentURI string + require.NoError(t, f.db.QueryRow(`SELECT deleted_at, parent_uri FROM comments WHERE uri = $1`, f.uri). + Scan(&deletedAt, &parentURI)) + require.True(t, deletedAt.Valid, "Y must remain soft-deleted") + require.Equal(t, f.firstParentURI, parentURI, "Y must retain its old parent") + require.Equal(t, before, notificationRowsForRecordOrSubject(t, f.db, f.uri), "repair statements must roll back") + require.NoError(t, f.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, f.uri).Scan(&storedRevision)) + require.Equal(t, f.deleteRevision, storedRevision, "failed repair must not advance the revision") +} diff --git a/internal/atproto/jetstream/comment_notification_resurrection_root_test.go b/internal/atproto/jetstream/comment_notification_resurrection_root_test.go new file mode 100644 index 0000000..ea712e4 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_resurrection_root_test.go @@ -0,0 +1,303 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type commentResurrectionRootFixture struct { + db *sql.DB + consumer *CommentEventConsumer + commentKey string + commentURI string + childReplyURI string + firstPostURI string + secondPostURI string + secondPostCID string + createdAt string + deleteRevision string + firstCount int + secondCount int +} + +func newCommentResurrectionRootFixture(t *testing.T) commentResurrectionRootFixture { + t.Helper() + ctx := context.Background() + db := testkit.DB(t) + _, firstPostURI, firstPostCID := setupRevFixtures(t, db) + // The post consumer opens a pending admission. Make P1 public while + // leaving P2 without an admission to exercise visibility-independent repair. + acceptanceKey := testkit.TID() + result, err := db.ExecContext(ctx, `UPDATE community_post_admissions + SET status = 'accepted', accepted_cid = $2, evaluated_cid = $2, + acceptance_uri = $3, acceptance_rkey = $4 + WHERE post_uri = $1`, firstPostURI, firstPostCID, + "at://"+revTestCommunity+"/social.coves.community.acceptance/"+acceptanceKey, acceptanceKey) + require.NoError(t, err) + updated, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, updated, "fixture: post consumer must open P1's admission") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM community_post_admissions + WHERE post_uri = $1 AND status = 'accepted'`, firstPostURI), "fixture: first post must be publicly admitted") + secondPostKey := testkit.TID() + secondPostURI := pv2URI(revTestAuthor, secondPostKey) + const secondPostCID = "bafyreiresurrectionsecondpost" + _, err = db.ExecContext(ctx, `INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, $2, $3, $4, $5, 'unadmitted target', NOW())`, + secondPostURI, secondPostCID, secondPostKey, revTestAuthor, revTestCommunity) + require.NoError(t, err) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM community_post_admissions WHERE post_uri = $1`, secondPostURI), + "fixture: second post exists but is not publicly admitted") + // Both the comment author and voter are indexed native users; the real vote + // consumer creates the group from an eligible vote on the indexed comment. + insertBridgedUserOnPDS(t, db, revTestCommenter, "revcommenter.test", bridgedTestNativePDS) + insertBridgedUserOnPDS(t, db, revTestVoter, "revvoter.test", bridgedTestNativePDS) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db))) + commentKey := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + commentKey + createRevision := testkit.TID() + record := revCommentRecord("B comments on the public first post", firstPostURI, firstPostCID, firstPostURI, firstPostCID) + record["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent(revTestCommenter, CommentCollection, + "create", commentKey, createRevision, "bafyreiresurrectionrootoriginal", time.Now().UnixMicro(), record))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1 AND deleted_at IS NULL`, commentURI)) + voteConsumer := NewVoteEventConsumer(postgres.NewVoteRepository(db), newMockUserService(), db, + WithVoteNotifications(postgres.NewNotificationRepository(db))) + deliverGroupVote(t, voteConsumer, revTestVoter, commentURI, "up", createdAt) + require.Equal(t, 1, groupCount(t, db, revTestCommenter, commentURI), "fixture: real vote must create B's group") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2 AND root_post_uri = $3 AND record_uri IS NULL`, + revTestCommenter, commentURI, firstPostURI), "fixture: group must point at the first post") + // The voter's reply Y to X notifies B with subject X, the same subject as B's group. + childReplyKey := testkit.TID() + childReplyURI := "at://" + revTestVoter + "/" + CommentCollection + "/" + childReplyKey + childRecord := revCommentRecord("voter replies to X", firstPostURI, firstPostCID, commentURI, "bafyreiresurrectionrootoriginal") + childRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent(revTestVoter, CommentCollection, + "create", childReplyKey, testkit.TID(), "bafyreiresurrectionrootchild", time.Now().UnixMicro(), childRecord))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE reason = 'commentReply' AND recipient_did = $1 AND subject_uri = $2 AND record_uri = $3 AND root_post_uri = $4`, + revTestCommenter, commentURI, childReplyURI, firstPostURI), "fixture: Y must notify B under the first post") + deleteRevision := testkit.TID() + require.Less(t, createRevision, deleteRevision) + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent(revTestCommenter, CommentCollection, + "delete", commentKey, deleteRevision, "", time.Now().UnixMicro(), nil))) + require.Equal(t, 1, groupCount(t, db, revTestCommenter, commentURI), "author delete must preserve the group") + var firstCount, secondCount int + require.NoError(t, db.QueryRowContext(ctx, `SELECT comment_count FROM posts WHERE uri = $1`, firstPostURI).Scan(&firstCount)) + require.NoError(t, db.QueryRowContext(ctx, `SELECT comment_count FROM posts WHERE uri = $1`, secondPostURI).Scan(&secondCount)) + require.Equal(t, 2, firstCount, "author delete retains X's placeholder in the old parent's count, beside Y") + require.Zero(t, secondCount) + return commentResurrectionRootFixture{db: db, consumer: consumer, commentKey: commentKey, + commentURI: commentURI, childReplyURI: childReplyURI, firstPostURI: firstPostURI, + secondPostURI: secondPostURI, secondPostCID: secondPostCID, createdAt: createdAt, + deleteRevision: deleteRevision, firstCount: firstCount, secondCount: secondCount} +} + +func (fixture commentResurrectionRootFixture) recreateEvent(t *testing.T, parentURI, parentCID string) *JetstreamEvent { + t.Helper() + return fixture.recreateEventUnderRoot(t, fixture.secondPostURI, fixture.secondPostCID, parentURI, parentCID) +} + +func (fixture commentResurrectionRootFixture) recreateEventUnderRoot(t *testing.T, rootURI, rootCID, parentURI, parentCID string) *JetstreamEvent { + t.Helper() + revision := testkit.TID() + require.Less(t, fixture.deleteRevision, revision) + record := revCommentRecord("B re-creates the comment on another root", rootURI, rootCID, parentURI, parentCID) + record["createdAt"] = fixture.createdAt + return revCommitEvent(revTestCommenter, CommentCollection, "create", fixture.commentKey, revision, + "bafyreiresurrectionrootrecreated", time.Now().UnixMicro(), record) +} + +func (fixture commentResurrectionRootFixture) assertGroupRoot(t *testing.T, rootURI string) { + t.Helper() + require.Equal(t, 1, groupCount(t, fixture.db, revTestCommenter, fixture.commentURI), + "exactly one upvote group must survive for B and X") + var storedRoot string + require.NoError(t, fixture.db.QueryRowContext(context.Background(), `SELECT root_post_uri FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2 AND record_uri IS NULL`, + revTestCommenter, fixture.commentURI).Scan(&storedRoot)) + require.Equal(t, rootURI, storedRoot, "group navigation must follow the comment's root") +} + +func (fixture commentResurrectionRootFixture) assertFailedRecreateRolledBack(t *testing.T) { + t.Helper() + var deletedAt sql.NullTime + var rootURI, parentURI, storedRevision string + require.NoError(t, fixture.db.QueryRow(`SELECT deleted_at, root_uri, parent_uri FROM comments WHERE uri = $1`, + fixture.commentURI).Scan(&deletedAt, &rootURI, &parentURI)) + require.True(t, deletedAt.Valid, "failed resurrection leaves X soft-deleted") + require.Equal(t, fixture.firstPostURI, rootURI) + require.Equal(t, fixture.firstPostURI, parentURI) + fixture.assertGroupRoot(t, fixture.firstPostURI) + var firstCount, secondCount int + require.NoError(t, fixture.db.QueryRow(`SELECT comment_count FROM posts WHERE uri = $1`, fixture.firstPostURI).Scan(&firstCount)) + require.NoError(t, fixture.db.QueryRow(`SELECT comment_count FROM posts WHERE uri = $1`, fixture.secondPostURI).Scan(&secondCount)) + require.Equal(t, fixture.firstCount, firstCount, "failed resurrection must not change the old parent's count") + require.Equal(t, fixture.secondCount, secondCount, "failed resurrection must not change the new parent's count") + require.NoError(t, fixture.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, + fixture.commentURI).Scan(&storedRevision)) + require.Equal(t, fixture.deleteRevision, storedRevision, "failed resurrection must not advance the tombstone rev") +} + +func TestCommentConsumer_DifferentRootResurrectionRepointsUpvoteGroupOnUnadmittedPost(t *testing.T) { + t.Parallel() + fixture := newCommentResurrectionRootFixture(t) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), + fixture.recreateEvent(t, fixture.secondPostURI, fixture.secondPostCID))) + var rootURI string + var deletedAt sql.NullTime + require.NoError(t, fixture.db.QueryRow(`SELECT root_uri, deleted_at FROM comments WHERE uri = $1`, + fixture.commentURI).Scan(&rootURI, &deletedAt)) + require.False(t, deletedAt.Valid, "X must be resurrected") + require.Equal(t, fixture.secondPostURI, rootURI) + fixture.assertGroupRoot(t, fixture.secondPostURI) +} + +func TestCommentConsumer_DifferentRootResurrectionWithUnsupportedParentRepointsUpvoteGroup(t *testing.T) { + t.Parallel() + fixture := newCommentResurrectionRootFixture(t) + parentURI := "at://" + revTestAuthor + "/app.bsky.feed.post/" + testkit.TID() + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), + fixture.recreateEvent(t, parentURI, "bafyreiresurrectionunsupportedparent")), + "unsupported parent collection must still index the re-created comment") + var rootURI, storedParentURI string + var deletedAt sql.NullTime + require.NoError(t, fixture.db.QueryRow(`SELECT root_uri, parent_uri, deleted_at FROM comments WHERE uri = $1`, + fixture.commentURI).Scan(&rootURI, &storedParentURI, &deletedAt)) + require.False(t, deletedAt.Valid) + require.Equal(t, fixture.secondPostURI, rootURI) + require.Equal(t, parentURI, storedParentURI) + fixture.assertGroupRoot(t, fixture.secondPostURI) +} + +// A comment's root is checked only for AT-URI shape, so a resurrection can name +// a non-post record as its root. root_post_uri must name a post, so the group +// then keeps its old root. +func TestCommentConsumer_DifferentRootResurrectionRepointsUpvoteGroupOnlyOntoAPost(t *testing.T) { + t.Parallel() + for _, scenario := range []struct { + name string + rootURI func(fixture commentResurrectionRootFixture) string + wantRoot func(fixture commentResurrectionRootFixture) string + }{ + { + name: "postv2 root is applied", + rootURI: func(fixture commentResurrectionRootFixture) string { return fixture.secondPostURI }, + wantRoot: func(fixture commentResurrectionRootFixture) string { return fixture.secondPostURI }, + }, + { + name: "comment root keeps the old root", + rootURI: func(commentResurrectionRootFixture) string { + return "at://" + revTestAuthor + "/" + CommentCollection + "/" + testkit.TID() + }, + wantRoot: func(fixture commentResurrectionRootFixture) string { return fixture.firstPostURI }, + }, + { + name: "app.bsky.feed.post root keeps the old root", + rootURI: func(commentResurrectionRootFixture) string { + return "at://" + revTestAuthor + "/app.bsky.feed.post/" + testkit.TID() + }, + wantRoot: func(fixture commentResurrectionRootFixture) string { return fixture.firstPostURI }, + }, + } { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + fixture := newCommentResurrectionRootFixture(t) + rootURI := scenario.rootURI(fixture) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), fixture.recreateEventUnderRoot(t, + rootURI, "bafyreiresurrectionrootguard", fixture.secondPostURI, fixture.secondPostCID))) + var storedRootURI string + var deletedAt sql.NullTime + require.NoError(t, fixture.db.QueryRow(`SELECT root_uri, deleted_at FROM comments WHERE uri = $1`, + fixture.commentURI).Scan(&storedRootURI, &deletedAt)) + require.False(t, deletedAt.Valid, "X must be resurrected") + require.Equal(t, rootURI, storedRootURI, "the comment itself records the root it names") + fixture.assertGroupRoot(t, scenario.wantRoot(fixture)) + }) + } +} + +func TestCommentConsumer_DifferentRootResurrectionRepointsOnlyTheAuthorsUpvoteGroup(t *testing.T) { + t.Parallel() + fixture := newCommentResurrectionRootFixture(t) + // Votes on X only group under X's author; a group on X for anyone else must + // keep its root when B's resurrection repoints B's own group. + _, err := fixture.db.Exec(`INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri) + VALUES ($1, 'upvote', $2, $3)`, revTestVoter, fixture.commentURI, fixture.firstPostURI) + require.NoError(t, err) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), + fixture.recreateEvent(t, fixture.secondPostURI, fixture.secondPostCID))) + fixture.assertGroupRoot(t, fixture.secondPostURI) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2 AND root_post_uri = $3`, + revTestVoter, fixture.commentURI, fixture.firstPostURI), "another recipient's group on X must keep its root") +} + +func TestCommentConsumer_DifferentRootResurrectionKeepsChildReplyRoot(t *testing.T) { + t.Parallel() + fixture := newCommentResurrectionRootFixture(t) + require.NoError(t, fixture.consumer.HandleEvent(context.Background(), + fixture.recreateEvent(t, fixture.secondPostURI, fixture.secondPostCID))) + fixture.assertGroupRoot(t, fixture.secondPostURI) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE reason = 'commentReply' AND recipient_did = $1 AND subject_uri = $2 AND record_uri = $3 AND root_post_uri = $4`, + revTestCommenter, fixture.commentURI, fixture.childReplyURI, fixture.firstPostURI), + "Y's reply notification to B has subject X but must keep Y's own root") +} + +type failingCommentUpvoteRootRepository struct { + notifications.Repository + failure error +} + +// ReplaceUpvoteGroupRootTx applies the real replacement before failing, so only +// the consumer's rollback can restore the group root. +func (repository *failingCommentUpvoteRootRepository) ReplaceUpvoteGroupRootTx(ctx context.Context, tx *sql.Tx, recipientDID, subjectURI, rootPostURI string) error { + if err := repository.Repository.ReplaceUpvoteGroupRootTx(ctx, tx, recipientDID, subjectURI, rootPostURI); err != nil { + return err + } + return repository.failure +} + +func TestCommentConsumer_DifferentRootResurrectionGroupWriteFailureRollsBack(t *testing.T) { + t.Parallel() + fixture := newCommentResurrectionRootFixture(t) + injectedError := errors.New("injected upvote group root replacement failure") + repository := &failingCommentUpvoteRootRepository{ + Repository: postgres.NewNotificationRepository(fixture.db), failure: injectedError, + } + consumer := NewCommentEventConsumer(postgres.NewCommentRepository(fixture.db), fixture.db, + WithCommentNotifications(repository)) + err := consumer.HandleEvent(context.Background(), fixture.recreateEvent(t, fixture.secondPostURI, fixture.secondPostCID)) + require.ErrorIs(t, err, injectedError, "resurrection must propagate a failed group-root replacement") + fixture.assertFailedRecreateRolledBack(t) +} + +func TestCommentConsumer_DifferentRootResurrectionNotificationFailureRollsBack(t *testing.T) { + t.Parallel() + fixture := newCommentResurrectionRootFixture(t) + injectedError := errors.New("injected different-root resurrection notification failure") + repository := &failingCommentNotificationRepository{ + delegate: postgres.NewNotificationRepository(fixture.db), failure: injectedError, + } + consumer := NewCommentEventConsumer(postgres.NewCommentRepository(fixture.db), fixture.db, + WithCommentNotifications(repository)) + err := consumer.HandleEvent(context.Background(), fixture.recreateEvent(t, fixture.secondPostURI, fixture.secondPostCID)) + require.ErrorIs(t, err, injectedError, "notification failure must roll back the resurrection") + require.Len(t, repository.intents, 1, "re-creation on a post must attempt a postReply notification") + fixture.assertFailedRecreateRolledBack(t) +} diff --git a/internal/atproto/jetstream/comment_notification_resurrection_test.go b/internal/atproto/jetstream/comment_notification_resurrection_test.go new file mode 100644 index 0000000..1a6428d --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_resurrection_test.go @@ -0,0 +1,189 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestCommentConsumer_ResurrectionLifecycle_DeleteKeepsRowsAndSameParentRecreateNotifiesOnlyNewRecipients(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + _, postURI, postCID := setupRevFixtures(t, db) + createdAt := activatedCommentNotificationTime(t, db, ctx) + mentionedID := testkit.UniqueID(t) + mentionedDID := "did:plc:" + mentionedID + mentionedHandle := mentionedID + ".test" + insertBridgedUserOnPDS(t, db, mentionedDID, mentionedHandle, bridgedTestNativePDS) + newID := testkit.UniqueID(t) + newDID, newHandle := "did:plc:"+newID, newID+".test" + insertBridgedUserOnPDS(t, db, newDID, newHandle, bridgedTestNativePDS) + + consumer := NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db))) + parentKey := testkit.TID() + parentURI := "at://" + revTestAuthor + "/" + CommentCollection + "/" + parentKey + parentCID := "bafyreiresurrectionparent" + parentRecord := revCommentRecord("B comments on B's post", postURI, postCID, postURI, postCID) + parentRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestAuthor, CommentCollection, "create", parentKey, testkit.TID(), parentCID, + time.Now().UnixMicro(), parentRecord, + )), "index B's parent comment") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, parentURI), + "fixture: B's parent comment must be indexed") + + commentKey := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + commentKey + commentRecord := func(content string) map[string]interface{} { + record := revCommentRecord(content, postURI, postCID, parentURI, parentCID) + record["createdAt"] = createdAt + record["facets"] = []interface{}{commentMentionFacet(t, content, mentionedHandle, mentionedDID)} + return record + } + firstCID := "bafyreiresurrectionoriginal" + firstRevision := testkit.TID() + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "create", commentKey, firstRevision, firstCID, + time.Now().UnixMicro(), commentRecord("A replies to B and mentions @"+mentionedHandle), + )), "index A's reply to B's comment mentioning D") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'commentReply' AND record_cid = $3`, + commentURI, revTestAuthor, firstCID), "B must receive exactly one reply to X") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, + commentURI, mentionedDID, firstCID), "D must receive exactly one mention from Y") + require.Equal(t, 2, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "Y must have only B's reply and D's mention") + before := notificationRowsForRecordOrSubject(t, db, commentURI) + require.Len(t, before, 2) + + deleteRevision := testkit.TID() + require.Less(t, firstRevision, deleteRevision, "delete revision must be newer than the create") + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "delete", commentKey, deleteRevision, "", + time.Now().UnixMicro(), nil, + )), "author deletes Y") + require.Equal(t, before, notificationRowsForRecordOrSubject(t, db, commentURI), + "author deletion must keep the original reply and mention rows") + + recreatedCID := "bafyreiresurrectionrecreated" + recreateRevision := testkit.TID() + require.Less(t, deleteRevision, recreateRevision, "re-create revision must be newer than the delete") + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "create", commentKey, recreateRevision, recreatedCID, + time.Now().UnixMicro(), func() map[string]interface{} { + content := "A re-creates Y mentioning @" + mentionedHandle + " @" + newHandle + record := commentRecord(content) + record["facets"] = []interface{}{ + commentMentionFacet(t, content, mentionedHandle, mentionedDID), + commentMentionFacet(t, content, newHandle, newDID), + } + return record + }(), + )), "author re-creates Y with the same parent and root") + after := notificationRowsForRecordOrSubject(t, db, commentURI) + require.Len(t, after, 3, "only F receives a new notification") + require.Equal(t, before[0], after[0], "B keeps the original id, CID, root and sort time") + require.Equal(t, before[1], after[1], "D keeps the original id, CID, root and sort time") + require.Equal(t, notificationRowSnapshot{recipient: newDID, reason: "mention", + recordCID: sql.NullString{String: recreatedCID, Valid: true}, rootPostURI: postURI}, + notificationRowSnapshot{recipient: after[2].recipient, reason: after[2].reason, + recordCID: after[2].recordCID, rootPostURI: after[2].rootPostURI}, "F alone receives the new CID") +} + +func TestCommentConsumer_SameParentResurrectionNotificationFailureRollsBack(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + _, postURI, postCID := setupRevFixtures(t, db) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db))) + commentKey := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + commentKey + originalCID := "bafyreisameparentrollbackoriginal" + createRevision := testkit.TID() + originalRecord := revCommentRecord("A replies to B's post", postURI, postCID, postURI, postCID) + originalRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "create", commentKey, createRevision, originalCID, + time.Now().UnixMicro(), originalRecord, + ))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'postReply'`, commentURI, revTestAuthor), + "fixture: the first create must notify B") + + deleteRevision := testkit.TID() + require.Less(t, createRevision, deleteRevision) + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "delete", commentKey, deleteRevision, "", + time.Now().UnixMicro(), nil, + ))) + var deletedAt time.Time + var deletedContent, deletedCID string + require.NoError(t, db.QueryRowContext(ctx, + `SELECT deleted_at, content, cid FROM comments WHERE uri = $1`, commentURI, + ).Scan(&deletedAt, &deletedContent, &deletedCID), "fixture: Y must be soft-deleted before re-creation") + require.Empty(t, deletedContent, "fixture: author delete must blank Y's content") + require.Equal(t, originalCID, deletedCID, "fixture: author delete must preserve Y's old CID") + keptRows := notificationRowsForRecordOrSubject(t, db, commentURI) + require.Len(t, keptRows, 1, "fixture: author deletion must keep Y's old notification") + var commentCountBefore int + require.NoError(t, db.QueryRowContext(ctx, `SELECT comment_count FROM posts WHERE uri = $1`, postURI). + Scan(&commentCountBefore)) + require.Equal(t, 1, commentCountBefore, "fixture: Y still contributes to its parent's comment count") + var storedRevision string + require.NoError(t, db.QueryRowContext(ctx, + `SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, commentURI, + ).Scan(&storedRevision)) + require.Equal(t, deleteRevision, storedRevision) + + injectedError := errors.New("injected same-parent resurrection notification write failure") + failingRepository := &failingCommentNotificationRepository{ + delegate: postgres.NewNotificationRepository(db), failure: injectedError, + } + failingConsumer := NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(failingRepository)) + recreateRevision := testkit.TID() + require.Less(t, deleteRevision, recreateRevision) + recreatedRecord := revCommentRecord("A re-creates Y on B's post", postURI, postCID, postURI, postCID) + recreatedRecord["createdAt"] = createdAt + err := failingConsumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "create", commentKey, recreateRevision, + "bafyreisameparentrollbackrecreated", time.Now().UnixMicro(), recreatedRecord, + )) + require.ErrorIs(t, err, injectedError, "same-parent resurrection must fail when its notification write fails") + require.Len(t, failingRepository.intents, 1, "re-creation must attempt B's postReply notification") + + var afterDeletedAt sql.NullTime + var afterContent, afterCID string + require.NoError(t, db.QueryRowContext(ctx, + `SELECT deleted_at, content, cid FROM comments WHERE uri = $1`, commentURI, + ).Scan(&afterDeletedAt, &afterContent, &afterCID)) + require.True(t, afterDeletedAt.Valid, "failed re-creation must leave Y soft-deleted") + require.True(t, afterDeletedAt.Time.Equal(deletedAt), "failed re-creation must preserve the delete timestamp") + require.Equal(t, deletedContent, afterContent, "failed re-creation must preserve Y's blanked content") + require.Equal(t, deletedCID, afterCID, "failed re-creation must preserve Y's old CID") + var commentCountAfter int + require.NoError(t, db.QueryRowContext(ctx, `SELECT comment_count FROM posts WHERE uri = $1`, postURI). + Scan(&commentCountAfter)) + require.Equal(t, commentCountBefore, commentCountAfter, "failed re-creation must not change the parent's count") + require.Equal(t, keptRows, notificationRowsForRecordOrSubject(t, db, commentURI), + "failed re-creation must preserve the kept notification") + require.NoError(t, db.QueryRowContext(ctx, + `SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, commentURI, + ).Scan(&storedRevision)) + require.Equal(t, deleteRevision, storedRevision, "failed re-creation must not advance Y's revision") +} diff --git a/internal/atproto/jetstream/comment_notification_withdrawn_gate_test.go b/internal/atproto/jetstream/comment_notification_withdrawn_gate_test.go new file mode 100644 index 0000000..f9f9282 --- /dev/null +++ b/internal/atproto/jetstream/comment_notification_withdrawn_gate_test.go @@ -0,0 +1,203 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func removeNotificationReferencePost(t *testing.T, db *sql.DB, uri string) { + t.Helper() + var community string + require.NoError(t, db.QueryRow(`SELECT community_did FROM posts WHERE uri = $1`, uri).Scan(&community)) + result, err := postgres.NewAdmissionRepository(db).ApplyRemoval(context.Background(), posts.ApplyRemovalCommand{ + CommunityDID: community, PostURI: uri, DecisionCode: string(posts.DecisionRuleViolation), + Watermark: posts.CommunityWatermark{Rev: testkit.TID()}, + }) + require.NoError(t, err) + require.Equal(t, posts.AdmissionApplied, result.Outcome) +} + +func TestCommentConsumer_WithdrawnReferencesSuppressCreate(t *testing.T) { + t.Parallel() + for _, kind := range []string{"removed root", "deleted root", "deleted parent comment", "removed distinct parent post", "live distinct parent post", "pending root"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + f := newCommentAuthorDeleteFixture(t) + parentURI, parentCID := f.postURI, f.postCID + rootURI, rootCID := f.postURI, f.postCID + if kind == "deleted parent comment" { + parentURI, parentCID = f.parent(t, revTestAuthor) + key := parentURI[len("at://"+revTestAuthor+"/"+CommentCollection+"/"):] + require.NoError(t, f.consumer.HandleEvent(context.Background(), revCommitEvent(revTestAuthor, CommentCollection, "delete", key, + testkit.TID(), "", time.Now().UnixMicro(), nil))) + require.Equal(t, 1, countRows(t, f.db, `SELECT count(*) FROM comments WHERE uri = $1 AND deleted_at IS NOT NULL`, parentURI)) + } + if kind == "removed distinct parent post" || kind == "live distinct parent post" { + key := testkit.TID() + parentURI = pv2URI(revTestAuthor, key) + parentCID = "bafyreiwithdrawnparentpost" + _, err := f.db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, $2, $3, $4, $5, 'distinct parent', NOW())`, parentURI, parentCID, key, revTestAuthor, revTestCommunity) + require.NoError(t, err) + } + if kind == "removed root" { + removeNotificationReferencePost(t, f.db, rootURI) + } + if kind == "removed distinct parent post" { + removeNotificationReferencePost(t, f.db, parentURI) + } + if kind == "deleted root" { + _, err := f.db.Exec(`UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, rootURI) + require.NoError(t, err) + } + mentioned, handle := f.mentionRecipient(t) + key := testkit.TID() + uri := "at://" + revTestCommenter + "/" + CommentCollection + "/" + key + content := "A mentions @" + handle + record := revCommentRecord(content, rootURI, rootCID, parentURI, parentCID) + record["createdAt"] = f.createdAt + record["facets"] = []interface{}{commentMentionFacet(t, content, handle, mentioned)} + require.NoError(t, f.consumer.HandleEvent(context.Background(), revCommitEvent(revTestCommenter, CommentCollection, "create", key, + testkit.TID(), "bafyreiwithdrawncomment", time.Now().UnixMicro(), record))) + require.Equal(t, 1, countRows(t, f.db, `SELECT count(*) FROM comments WHERE uri = $1`, uri)) + if kind == "pending root" { + require.Equal(t, 1, countRows(t, f.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'postReply'`, uri, revTestAuthor)) + return + } + if kind == "live distinct parent post" { + require.Equal(t, 1, countRows(t, f.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, uri, mentioned)) + return + } + require.Zero(t, countRows(t, f.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri), + "withdrawn root or parent must suppress both reply and mention") + }) + } +} + +func TestCommentConsumer_RemovedRootSuppressesEditAndActiveRecreate(t *testing.T) { + t.Parallel() + for _, operation := range []string{"update", "active recreate"} { + t.Run(operation, func(t *testing.T) { + t.Parallel() + f := newMentionEditFixture(t) + added := f.recipient(t) + f.create(t) + require.Equal(t, 1, mentionEditRows(t, f, f.postAuthor(t), "postReply")) + removeNotificationReferencePost(t, f.gate.db, f.post.uri) + var err error + if operation == "update" { + err = f.update(t, f.createdAt, time.Now().Add(time.Second).UnixMicro(), added) + } else { + event, content := activeRecreateEvent(t, f, f.createdAt, added) + err = f.consumer.HandleEvent(context.Background(), event) + require.NoError(t, err) + requireActiveRecreateApplied(t, f, content) + } + require.NoError(t, err) + if operation == "update" { + var content string + require.NoError(t, f.gate.db.QueryRow(`SELECT content FROM comments WHERE uri = $1`, f.uri).Scan(&content)) + require.Contains(t, content, added.handle) + } + require.Zero(t, mentionEditRows(t, f, added, "mention"), "removed root must suppress the new mention") + }) + } +} + +func TestCommentConsumer_RemovedRootSuppressesSameParentResurrection(t *testing.T) { + t.Parallel() + f := newCommentAuthorDeleteFixture(t) + parent, parentCID := f.parent(t, revTestAuthor) + key := testkit.TID() + firstRevision := testkit.TID() + uri := f.createReply(t, key, firstRevision, "bafyreiwithdrawninitial", f.replyRecord("A replies", parent, parentCID)) + before := notificationRowsForRecordOrSubject(t, f.db, uri) + require.Len(t, before, 1) + deleteRevision := testkit.TID() + require.Less(t, firstRevision, deleteRevision) + require.NoError(t, f.consumer.HandleEvent(context.Background(), revCommitEvent(revTestCommenter, CommentCollection, "delete", key, + deleteRevision, "", time.Now().UnixMicro(), nil))) + removeNotificationReferencePost(t, f.db, f.postURI) + added, handle := f.mentionRecipient(t) + recreateRevision := testkit.TID() + require.Less(t, deleteRevision, recreateRevision) + require.NoError(t, f.consumer.HandleEvent(context.Background(), revCommitEvent(revTestCommenter, CommentCollection, "create", key, + recreateRevision, "bafyreiwithdrawnrecreated", time.Now().UnixMicro(), f.mentionedReply(t, parent, parentCID, added, handle)))) + require.Equal(t, before, notificationRowsForRecordOrSubject(t, f.db, uri), "kept notification IDs and sort times must not change") + require.Zero(t, countRows(t, f.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, uri, added)) +} + +func TestCommentConsumer_RestoredPostOnlyNotifiesNewComments(t *testing.T) { + t.Parallel() + f := newCommentAuthorDeleteFixture(t) + removeNotificationReferencePost(t, f.db, f.postURI) + first := f.createReply(t, testkit.TID(), testkit.TID(), "bafyreiwithdrawnwhile", f.replyRecord("A replies while removed", f.postURI, f.postCID)) + var community string + require.NoError(t, f.db.QueryRow(`SELECT community_did FROM posts WHERE uri = $1`, f.postURI).Scan(&community)) + admissions := postgres.NewAdmissionRepository(f.db) + removed, err := admissions.ApplyRemovalDelete(context.Background(), posts.CommunityDeleteCommand{ + CommunityDID: community, PostURI: f.postURI, Watermark: posts.CommunityWatermark{Rev: testkit.TID()}, + }) + require.NoError(t, err) + require.Equal(t, posts.AdmissionApplied, removed.Outcome) + key := testkit.TID() + accepted, err := admissions.ApplyAcceptance(context.Background(), posts.ApplyAcceptanceCommand{ + CommunityDID: community, PostURI: f.postURI, AcceptanceURI: "at://" + community + "/" + posts.AcceptanceCollection + "/" + key, + AcceptanceRkey: key, PinnedCID: f.postCID, Watermark: posts.CommunityWatermark{Rev: testkit.TID()}, + }) + require.NoError(t, err) + require.Equal(t, posts.AdmissionApplied, accepted.Outcome) + state, err := admissions.Get(context.Background(), community, f.postURI) + require.NoError(t, err) + require.Equal(t, posts.AdmissionStatusAccepted, state.Status) + second := f.createReply(t, testkit.TID(), testkit.TID(), "bafyreiwithdrawnafter", f.replyRecord("A replies after restore", f.postURI, f.postCID)) + require.Equal(t, 1, countRows(t, f.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'postReply'`, second, revTestAuthor)) + require.Zero(t, countRows(t, f.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, first)) +} + +// A thread reference with no indexed row cannot be checked for withdrawal, so +// it opens no fan-out: the comment still indexes as an orphan, but neither its +// create nor an edit that adds a mention notifies anyone, including the +// reference's authority and every mentioned user. +func TestCommentConsumer_UnindexedThreadReferenceSuppressesCreateAndEdit(t *testing.T) { + t.Parallel() + for _, missing := range []string{"root and parent postv2", "parent comment"} { + t.Run(missing, func(t *testing.T) { + t.Parallel() + f := newMentionEditFixture(t) + victim := f.postAuthor(t) + if missing == "root and parent postv2" { + f.post.uri = "at://" + victim.did + "/" + posts.PostV2Collection + "/" + testkit.TID() + f.post.cid = "bafyreiunindexedroot" + f.parentURI, f.parentCID = f.post.uri, f.post.cid + } else { + f.parentURI = "at://" + victim.did + "/" + CommentCollection + "/" + testkit.TID() + f.parentCID = "bafyreiunindexedparent" + } + require.Zero(t, countRows(t, f.gate.db, `SELECT count(*) FROM posts WHERE uri = $1`, f.parentURI)) + require.Zero(t, countRows(t, f.gate.db, `SELECT count(*) FROM comments WHERE uri = $1`, f.parentURI)) + mentioned := f.recipient(t) + f.create(t, mentioned) + require.Zero(t, countRows(t, f.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, f.uri), + "an unindexed thread reference must suppress the create's reply and mention rows") + + added := f.recipient(t) + require.NoError(t, f.update(t, f.createdAt, time.Now().Add(time.Second).UnixMicro(), mentioned, added)) + var content string + require.NoError(t, f.gate.db.QueryRow(`SELECT content FROM comments WHERE uri = $1`, f.uri).Scan(&content)) + require.Contains(t, content, added.handle, "the edit must still be indexed") + require.Zero(t, countRows(t, f.gate.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, f.uri), + "an unindexed thread reference must suppress the edit's new mention") + }) + } +} diff --git a/internal/atproto/jetstream/comment_notifications_test.go b/internal/atproto/jetstream/comment_notifications_test.go new file mode 100644 index 0000000..9c0402c --- /dev/null +++ b/internal/atproto/jetstream/comment_notifications_test.go @@ -0,0 +1,524 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/users" + "Coves/internal/crypto/credentialcipher/credentialciphertest" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommentConsumer_WritesReplyNotificationsOnNewInsert(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + authorDID := "did:plc:" + uniqueID + "author" + commenterDID := "did:plc:" + uniqueID + "commenter" + communityDID := "did:plc:" + uniqueID + "community" + + for _, user := range []struct{ did, handle string }{ + {authorDID, uniqueID + "author.test"}, + {commenterDID, uniqueID + "commenter.test"}, + } { + _, err := db.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + user.did, user.handle, bridgedTestNativePDS) + require.NoError(t, err, "index notification recipient and commenter") + } + _, err := db.ExecContext(ctx, + `INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, pds_url, created_at) + VALUES ($1, $2, $3, $4, $4, $4, $5, NOW())`, + communityDID, uniqueID+"community.test", "Notification test community", authorDID, bridgedTestNativePDS) + require.NoError(t, err, "index the post's community") + + userService := newMockUserService() + userService.users[authorDID] = &users.User{DID: authorDID, Handle: uniqueID + "author.test"} + postConsumer := NewPostEventConsumer( + postgres.NewPostRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), + userService, db, + WithAdmissions(postgres.NewAdmissionRepository(db)), + ) + postKey := testkit.TID() + postURI := pv2URI(authorDID, postKey) + postCID := "bafyreicommentnotificationpost" + require.NoError(t, postConsumer.HandleEvent(ctx, pv2Event( + authorDID, "create", postKey, testkit.TID(), postCID, time.Now().UnixMicro(), + pv2Record(communityDID, "Reply notification target", "A post to reply to"), + )), "index B's author-owned post through the post consumer") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1 AND author_did = $2`, postURI, authorDID)) + + // Keep the record's display time fixed across all events in this test, after + // activation but before indexing. The default migration cutoff is NOW(). + var recordCreatedAt time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT NOW() - INTERVAL '1 minute'`).Scan(&recordCreatedAt)) + _, err = db.ExecContext(ctx, `UPDATE notification_activation SET activated_at = $1`, recordCreatedAt.Add(-time.Minute)) + require.NoError(t, err, "activate notifications before the test record's createdAt") + createdAt := recordCreatedAt.UTC().Format(time.RFC3339Nano) + + consumer := NewCommentEventConsumer( + postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db)), + ) + commentKey := testkit.TID() + commentURI := "at://" + commenterDID + "/" + CommentCollection + "/" + commentKey + commentCID := "bafyreicommentnotificationreply" + commentRecord := revCommentRecord("A replies to B's post", postURI, postCID, postURI, postCID) + commentRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + commenterDID, CommentCollection, "create", commentKey, testkit.TID(), commentCID, + time.Now().UnixMicro(), commentRecord, + )), "index A's top-level reply") + var clockAfterIndex time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT clock_timestamp()`).Scan(&clockAfterIndex)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "fixture: A's reply must be indexed before asserting its notification") + + var revisionUpdatedAt time.Time + require.NoError(t, db.QueryRowContext(ctx, + `SELECT updated_at FROM jetstream_record_revs WHERE record_uri = $1`, commentURI, + ).Scan(&revisionUpdatedAt), "fixture: the comment must have a rev-gate timestamp") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications`), + "missing postReply notification for A's newly indexed comment on B's post") + + var recipientDID, reason, actorDID, recordURI, recordCID, subjectURI, rootPostURI string + var storedCreatedAt, sortAt time.Time + require.NoError(t, db.QueryRowContext(ctx, + `SELECT recipient_did, reason, actor_did, record_uri, record_cid, subject_uri, + root_post_uri, record_created_at, sort_at FROM notifications WHERE record_uri = $1`, + commentURI, + ).Scan(&recipientDID, &reason, &actorDID, &recordURI, &recordCID, &subjectURI, + &rootPostURI, &storedCreatedAt, &sortAt)) + assert.Equal(t, authorDID, recipientDID) + assert.Equal(t, "postReply", reason) + assert.Equal(t, commenterDID, actorDID) + assert.Equal(t, commentURI, recordURI) + assert.Equal(t, commentCID, recordCID) + assert.Equal(t, postURI, subjectURI) + assert.Equal(t, postURI, rootPostURI) + assert.Truef(t, storedCreatedAt.Equal(recordCreatedAt), "record_created_at = %s, want record createdAt %s", storedCreatedAt, recordCreatedAt) + // The rev gate's updated_at is the index transaction's now(); sort_at is the + // notification INSERT's clock_timestamp(), taken later in that transaction. + assert.Truef(t, sortAt.After(revisionUpdatedAt) && !sortAt.After(clockAfterIndex), + "sort_at = %s, want the INSERT's clock_timestamp() in (index transaction now() %s, %s]", + sortAt, revisionUpdatedAt, clockAfterIndex) + + // B's own reply to B's post is indexed as the parent comment but must not + // notify B. The next comment targets this parent, not the root post. + parentKey := testkit.TID() + parentURI := "at://" + authorDID + "/" + CommentCollection + "/" + parentKey + parentCID := "bafyreicommentnotificationparent" + parentRecord := revCommentRecord("B comments on their own post", postURI, postCID, postURI, postCID) + parentRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + authorDID, CommentCollection, "create", parentKey, testkit.TID(), parentCID, + time.Now().UnixMicro(), parentRecord, + ))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, parentURI)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications`), + "B's own comment on B's post must not create a self-notification") + + replyKey := testkit.TID() + replyURI := "at://" + commenterDID + "/" + CommentCollection + "/" + replyKey + replyCID := "bafyreicommentnotificationnested" + replyRecord := revCommentRecord("A replies to B's comment", postURI, postCID, parentURI, parentCID) + replyRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + commenterDID, CommentCollection, "create", replyKey, testkit.TID(), replyCID, + time.Now().UnixMicro(), replyRecord, + ))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, replyURI)) + require.Equal(t, 2, countRows(t, db, `SELECT count(*) FROM notifications`), + "A's commentReply must add exactly one notification to B's existing postReply") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = $2 AND actor_did = $3 AND record_uri = $4 + AND record_cid = $5 AND subject_uri = $6 AND root_post_uri = $7`, + authorDID, "commentReply", commenterDID, replyURI, replyCID, parentURI, postURI), + "missing commentReply notification targeting B's parent comment") +} + +// The migration's default activation is NOW(), so a record timestamped before +// indexing needs an earlier cutoff to be eligible for a notification. +func activatedCommentNotificationTime(t *testing.T, db *sql.DB, ctx context.Context) string { + t.Helper() + var createdAt time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT NOW() - INTERVAL '1 minute'`).Scan(&createdAt)) + _, err := db.ExecContext(ctx, `UPDATE notification_activation SET activated_at = $1`, createdAt.Add(-time.Minute)) + require.NoError(t, err) + return createdAt.UTC().Format(time.RFC3339Nano) +} + +func TestCommentConsumer_ReplyNotificationLegacyPostAuthor(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + suffix := testkit.UniqueID(t) + authorDID := "did:plc:" + suffix + "legacyauthor" + communityDID := "did:plc:" + suffix + "legacycommunity" + commenterDID := "did:plc:" + suffix + "legacycommenter" + postKey := testkit.TID() + postURI := "at://" + communityDID + "/social.coves.community.post/" + postKey + seedIndexedPost(t, db, postURI, communityDID, authorDID, postKey) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1 AND author_did = $2`, postURI, authorDID), + "fixture: legacy URI authority is the community; the post author is a user") + createdAt := activatedCommentNotificationTime(t, db, ctx) + + consumer := NewCommentEventConsumer( + postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db)), + ) + commentKey := testkit.TID() + commentURI := "at://" + commenterDID + "/" + CommentCollection + "/" + commentKey + commentRecord := revCommentRecord("A replies to C's legacy post", postURI, "bafredrivesubject", postURI, "bafredrivesubject") + commentRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + commenterDID, CommentCollection, "create", commentKey, testkit.TID(), "bafyreplylegacy", + time.Now().UnixMicro(), commentRecord, + ))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "fixture: the top-level reply must be indexed") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications`), + "a top-level reply to a legacy post must notify its author exactly once") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND reason = 'postReply' AND actor_did = $2 + AND record_uri = $3 AND subject_uri = $4 AND root_post_uri = $4`, + authorDID, commenterDID, commentURI, postURI), + "the recipient is posts.author_did, never the legacy URI's community DID") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE recipient_did = $1`, communityDID)) +} + +func TestCommentConsumer_ReplyNotificationDuplicateDelivery(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + _, postURI, postCID := setupRevFixtures(t, db) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := NewCommentEventConsumer( + postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db)), + ) + commentKey := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + commentKey + commentCID := "bafyreplyduplicate" + commentRecord := revCommentRecord("A replies to B's post", postURI, postCID, postURI, postCID) + commentRecord["createdAt"] = createdAt + event := revCommitEvent(revTestCommenter, CommentCollection, "create", commentKey, + testkit.TID(), commentCID, time.Now().UnixMicro(), commentRecord) + require.NoError(t, consumer.HandleEvent(ctx, event)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "fixture: the first delivery must index A's comment") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "the first delivery must create one reply notification") + var firstCID string + var firstSortAt time.Time + require.NoError(t, db.QueryRowContext(ctx, + `SELECT record_cid, sort_at FROM notifications WHERE record_uri = $1`, commentURI, + ).Scan(&firstCID, &firstSortAt)) + require.Equal(t, commentCID, firstCID) + + require.NoError(t, consumer.HandleEvent(ctx, event), "an identical create event must be idempotent") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications`), + "duplicate delivery must leave exactly one notification in the inbox") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "a duplicate delivery must not create a second notification") + var replayCID string + var replaySortAt time.Time + require.NoError(t, db.QueryRowContext(ctx, + `SELECT record_cid, sort_at FROM notifications WHERE record_uri = $1`, commentURI, + ).Scan(&replayCID, &replaySortAt)) + require.Equal(t, firstCID, replayCID, "the duplicate must preserve the first record CID") + require.True(t, replaySortAt.Equal(firstSortAt), "the duplicate must preserve the first sort_at") +} + +func TestCommentConsumer_ReplyNotificationStaleCreateReplay(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + _, postURI, postCID := setupRevFixtures(t, db) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := NewCommentEventConsumer( + postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db)), + ) + commentKey := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + commentKey + olderRev := testkit.TID() + newerRev := testkit.TID() + require.Less(t, olderRev, newerRev, "fixture: repo revisions must be lexicographically ordered") + commentRecord := revCommentRecord("A replies to B's post", postURI, postCID, postURI, postCID) + commentRecord["createdAt"] = createdAt + const newerCID = "bafyreplynewer" + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "create", commentKey, newerRev, newerCID, + time.Now().UnixMicro(), commentRecord, + ))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "fixture: the newer create must index A's comment") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "the newer create must write one reply notification") + var firstCID string + var firstSortAt time.Time + require.NoError(t, db.QueryRowContext(ctx, + `SELECT record_cid, sort_at FROM notifications WHERE record_uri = $1`, commentURI, + ).Scan(&firstCID, &firstSortAt)) + require.Equal(t, newerCID, firstCID) + + // The stale revision threads under a different indexed user's post, so a + // fan-out for it would notify that user instead of deduplicating into B's row. + const otherAuthorDID = revTestPrefix + "staleotherauthor" + insertBridgedUser(t, db, otherAuthorDID, "revstaleotherauthor.test") + otherPostURI := pv2URI(otherAuthorDID, "revstaleotherpost") + staleRecord := revCommentRecord("A replied to C's post at an older revision", + otherPostURI, "bafyrevstaleotherpost", otherPostURI, "bafyrevstaleotherpost") + staleRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "create", commentKey, olderRev, "bafyreplyolder", + time.Now().Add(time.Minute).UnixMicro(), staleRecord, + )), "an older revision replayed later must not replace the newer notification") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE recipient_did = $1`, otherAuthorDID), + "a rev-gated stale create must not fan out to the recipient its own threading names") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications`), + "a stale create must leave exactly one notification in the inbox") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "the stale create must not add another notification") + var replayCID string + var replaySortAt time.Time + require.NoError(t, db.QueryRowContext(ctx, + `SELECT record_cid, sort_at FROM notifications WHERE record_uri = $1`, commentURI, + ).Scan(&replayCID, &replaySortAt)) + require.Equal(t, newerCID, replayCID, "the stale CID must never overwrite the newer CID") + require.True(t, replaySortAt.Equal(firstSortAt), "the stale replay must preserve the first sort_at") +} + +// A thread URI that passes the consumer's lenient validateATURI but not +// syntax.ParseATURI is a payload defect: the comment still indexes as it did +// before notifications existed, and no one is notified. +func TestCommentConsumer_UnparsableThreadURIIndexesWithoutNotification(t *testing.T) { + t.Parallel() + const parentAuthorDID = revTestPrefix + "unparsableparent" + for _, test := range []struct { + threading func(postURI string) (rootURI, parentURI string) + name string + }{ + { + name: "top-level reply to a trailing-slash post URI", + threading: func(postURI string) (string, string) { + return postURI + "/", postURI + "/" + }, + }, + { + name: "nested reply to a trailing-slash parent comment URI", + threading: func(postURI string) (string, string) { + return postURI, "at://" + parentAuthorDID + "/" + CommentCollection + "/" + testkit.TID() + "/" + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + _, postURI, postCID := setupRevFixtures(t, db) + insertBridgedUser(t, db, parentAuthorDID, "revunparsableparent.test") + createdAt := activatedCommentNotificationTime(t, db, ctx) + rootURI, parentURI := test.threading(postURI) + for _, uri := range []string{rootURI, parentURI} { + require.NoErrorf(t, validateATURI(uri), "fixture: %s must pass the consumer's validation", uri) + } + _, parseErr := syntax.ParseATURI(parentURI) + require.Error(t, parseErr, "fixture: the parent URI must not parse as a strict AT-URI") + + consumer := NewCommentEventConsumer( + postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db)), + ) + commentKey := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + commentKey + commentRecord := revCommentRecord("A replies through a malformed thread URI", + rootURI, postCID, parentURI, "bafyunparsableparent") + commentRecord["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + revTestCommenter, CommentCollection, "create", commentKey, testkit.TID(), "bafyunparsablereply", + time.Now().UnixMicro(), commentRecord, + )), "an unparsable thread URI must not fail indexing") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "the comment must index despite its unparsable thread URI") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE recipient_did IN ($1, $2)`, + revTestAuthor, parentAuthorDID), "no indexed author may be notified through an unparsable thread URI") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI)) + }) + } +} + +type failingCommentNotificationRepository struct { + delegate notifications.Repository + failure error + intents []notifications.Intent +} + +func (repository *failingCommentNotificationRepository) ErasureGateTx(ctx context.Context, tx *sql.Tx, actorDID string) (bool, error) { + return repository.delegate.ErasureGateTx(ctx, tx, actorDID) +} + +func (repository *failingCommentNotificationRepository) LookupsTx(tx *sql.Tx) notifications.Lookups { + return repository.delegate.LookupsTx(tx) +} + +func (repository *failingCommentNotificationRepository) ApplyUpvoteGroupTx(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + return repository.delegate.ApplyUpvoteGroupTx(ctx, tx, intent) +} + +func (repository *failingCommentNotificationRepository) RecordPostAuthorDeleteWithdrawalTx(ctx context.Context, tx *sql.Tx, postURI string) error { + return repository.delegate.RecordPostAuthorDeleteWithdrawalTx(ctx, tx, postURI) +} + +func (repository *failingCommentNotificationRepository) RepairResurrectedCommentNotificationsTx(ctx context.Context, tx *sql.Tx, recordURI, replySubjectURI, rootPostURI string) error { + return repository.delegate.RepairResurrectedCommentNotificationsTx(ctx, tx, recordURI, replySubjectURI, rootPostURI) +} + +func (repository *failingCommentNotificationRepository) DeleteReplyRecipientMentionsTx(ctx context.Context, tx *sql.Tx, recordURI string) error { + return repository.delegate.DeleteReplyRecipientMentionsTx(ctx, tx, recordURI) +} + +func (repository *failingCommentNotificationRepository) ReplaceUpvoteGroupRootTx(ctx context.Context, tx *sql.Tx, recipientDID, subjectURI, rootPostURI string) error { + return repository.delegate.ReplaceUpvoteGroupRootTx(ctx, tx, recipientDID, subjectURI, rootPostURI) +} + +func (repository *failingCommentNotificationRepository) ApplyTx(_ context.Context, _ *sql.Tx, intents []notifications.Intent) error { + repository.intents = intents + return repository.failure +} + +func TestCommentConsumer_NotificationWriteFailureRollsBackCommentAndRevForRetry(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + _, postURI, postCID := setupRevFixtures(t, db) + createdAt := activatedCommentNotificationTime(t, db, ctx) + + var originalCommentCount int + require.NoError(t, db.QueryRowContext(ctx, + `SELECT comment_count FROM posts WHERE uri = $1`, postURI, + ).Scan(&originalCommentCount), "fixture: B's post must exist") + + commentKey := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + commentKey + commentRecord := revCommentRecord("A replies to B's post", postURI, postCID, postURI, postCID) + commentRecord["createdAt"] = createdAt + event := revCommitEvent(revTestCommenter, CommentCollection, "create", commentKey, + testkit.TID(), "bafyreplyrollback", time.Now().UnixMicro(), commentRecord) + + injectedError := errors.New("injected notification write failure") + failingRepository := &failingCommentNotificationRepository{ + delegate: postgres.NewNotificationRepository(db), + failure: injectedError, + } + failingConsumer := NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(failingRepository)) + err := failingConsumer.HandleEvent(ctx, event) + require.ErrorIs(t, err, injectedError) + assert.NotErrorIs(t, err, ErrPermanentEvent) + assert.NotErrorIs(t, err, ErrUnresolvedReference) + assert.False(t, skipsInlineRetries(err), "a notification write failure must enter the connector's transient retry lane") + require.Len(t, failingRepository.intents, 1, "fixture: fan-out must request B's postReply before ApplyTx fails") + assert.Equal(t, revTestAuthor, failingRepository.intents[0].RecipientDID) + assert.Equal(t, notifications.ReasonPostReply, failingRepository.intents[0].Reason) + + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "the comment insert must roll back") + var commentCountAfterFailure int + require.NoError(t, db.QueryRowContext(ctx, + `SELECT comment_count FROM posts WHERE uri = $1`, postURI, + ).Scan(&commentCountAfterFailure)) + assert.Equal(t, originalCommentCount, commentCountAfterFailure, "the post count must roll back") + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM jetstream_record_revs WHERE record_uri = $1`, commentURI), + "the failed delivery must not claim the revision") + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "the failed delivery must not leave a notification") + + retryingConsumer := NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db))) + require.NoError(t, retryingConsumer.HandleEvent(ctx, event), "the identical event must succeed on retry") + assert.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI)) + var commentCountAfterRetry int + require.NoError(t, db.QueryRowContext(ctx, + `SELECT comment_count FROM posts WHERE uri = $1`, postURI, + ).Scan(&commentCountAfterRetry)) + assert.Equal(t, originalCommentCount+1, commentCountAfterRetry) + assert.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = $3`, + commentURI, revTestAuthor, "postReply"), "the retry must notify B exactly once") +} + +// Without the activation row the notification cutoff is unknown, so the reply +// must not be indexed without its notification: the whole delivery fails +// transiently and the identical event succeeds once the row is restored. +func TestCommentConsumer_MissingActivationRowFailsIndexingForRetry(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + _, postURI, postCID := setupRevFixtures(t, db) + createdAt := activatedCommentNotificationTime(t, db, ctx) + + var activatedAt time.Time + require.NoError(t, db.QueryRowContext(ctx, + `SELECT activated_at FROM notification_activation`, + ).Scan(&activatedAt), "fixture: the activation row must exist before it is removed") + result, err := db.ExecContext(ctx, `DELETE FROM notification_activation`) + require.NoError(t, err) + rowsAffected, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rowsAffected, "fixture: the singleton activation row must be removed") + + var originalCommentCount int + require.NoError(t, db.QueryRowContext(ctx, + `SELECT comment_count FROM posts WHERE uri = $1`, postURI, + ).Scan(&originalCommentCount), "fixture: B's post must exist") + + commentKey := testkit.TID() + commentURI := "at://" + revTestCommenter + "/" + CommentCollection + "/" + commentKey + commentRecord := revCommentRecord("A replies to B's post", postURI, postCID, postURI, postCID) + commentRecord["createdAt"] = createdAt + event := revCommitEvent(revTestCommenter, CommentCollection, "create", commentKey, + testkit.TID(), "bafyreplymissingactivation", time.Now().UnixMicro(), commentRecord) + + consumer := NewCommentEventConsumer(postgres.NewCommentRepository(db), db, + WithCommentNotifications(postgres.NewNotificationRepository(db))) + err = consumer.HandleEvent(ctx, event) + require.ErrorIs(t, err, postgres.ErrNotificationActivationMissing) + assert.NotErrorIs(t, err, ErrPermanentEvent) + assert.NotErrorIs(t, err, ErrUnresolvedReference) + assert.False(t, skipsInlineRetries(err), "a missing activation row must enter the connector's transient retry lane") + + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI), + "the comment must not be indexed without its notification") + var commentCountAfterFailure int + require.NoError(t, db.QueryRowContext(ctx, + `SELECT comment_count FROM posts WHERE uri = $1`, postURI, + ).Scan(&commentCountAfterFailure)) + assert.Equal(t, originalCommentCount, commentCountAfterFailure, "the post count must roll back") + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM jetstream_record_revs WHERE record_uri = $1`, commentURI), + "the failed delivery must not claim the revision") + assert.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, commentURI), + "the failed delivery must not leave a notification") + + _, err = db.ExecContext(ctx, `INSERT INTO notification_activation (activated_at) VALUES ($1)`, activatedAt) + require.NoError(t, err, "fixture: restore the activation row") + require.NoError(t, consumer.HandleEvent(ctx, event), "the identical event must succeed once the row is restored") + assert.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM comments WHERE uri = $1`, commentURI)) + assert.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = $3`, + commentURI, revTestAuthor, "postReply"), "the retry must notify B exactly once") +} diff --git a/internal/atproto/jetstream/post_consumer.go b/internal/atproto/jetstream/post_consumer.go index 7deacfa..7d0ac7b 100644 --- a/internal/atproto/jetstream/post_consumer.go +++ b/internal/atproto/jetstream/post_consumer.go @@ -14,6 +14,7 @@ import ( "Coves/internal/core/communities" "Coves/internal/core/embeds" "Coves/internal/core/moderation" + "Coves/internal/core/notifications" "Coves/internal/core/posts" "Coves/internal/core/richtext" "Coves/internal/core/users" @@ -34,6 +35,9 @@ type PostEventConsumer struct { // bridgeTrust gates whether a post's author repo may assert bridgedStats. // nil means default-deny (bridgedStats are ignored for every post). bridgeTrust *BridgeTrust + // notifications writes mention notifications for newly indexed posts in + // the same transaction as the post. nil disables post notifications. + notifications notifications.Repository // identityResolver is used only when relay scheduling delivers a post // before its author's profile. The identity is admitted only when its PDS // passes bridgeTrust. @@ -73,6 +77,15 @@ func WithPostIdentityResolver(resolver identity.Resolver) PostEventConsumerOptio return func(c *PostEventConsumer) { c.identityResolver = resolver } } +// WithPostNotifications makes the consumer write eligible mentions when it +// indexes a new postv2 record. +func WithPostNotifications(repository notifications.Repository) PostEventConsumerOption { + return func(c *PostEventConsumer) { c.notifications = repository } +} + +// NotificationsWired reports whether the consumer writes post mentions. +func (c *PostEventConsumer) NotificationsWired() bool { return c.notifications != nil } + // NewPostEventConsumer creates a new Jetstream consumer for post events func NewPostEventConsumer( postRepo posts.Repository, @@ -167,19 +180,24 @@ func indexedAtForEvent(timeUS int64) time.Time { // SOFT, never hard: the row is the rev gate's tombstone, the comment thread's // parent, and what moderation still reads. // +// When notifications are wired, take the author's erasure lock before touching +// the post row, then record its pre-withdrawal public visibility after the soft +// delete (including a zero-row delete). Notifications remain on author deletion. +// Both actions share the rev-gated transaction. +// // The applied flag exists for the author-repo path's acceptance sweep, which // must fire once per deletion rather than once per DELIVERY of it: the // connector rewinds its cursor after every reconnect, so a tombstone that // re-swept on each redelivery would put an authenticated PDS round trip behind // every replayed event. -func (c *PostEventConsumer) tombstoneRecordIfRevWins(ctx context.Context, uri, rev string) (bool, error) { +func (c *PostEventConsumer) tombstoneRecordIfRevWins(ctx context.Context, uri, rev, authorDID string) (bool, error) { // REV GATE + soft delete in one transaction (the repo's SoftDelete is not // transaction-aware, and the delete's rev must be recorded atomically with // the tombstone: it is what rejects a stale cross-feed copy of the CREATE // arriving later and resurrecting the post). The gate row is advanced even // when the post was never indexed, so the late create of an already-deleted // record is rejected too. - tx, err := c.db.BeginTx(ctx, nil) + tx, err := c.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return false, fmt.Errorf("failed to begin transaction: %w", err) } @@ -198,6 +216,12 @@ func (c *PostEventConsumer) tombstoneRecordIfRevWins(ctx context.Context, uri, r return false, nil } + if c.notifications != nil { + if _, err := c.notifications.ErasureGateTx(ctx, tx, authorDID); err != nil { + return false, fmt.Errorf("check post author erasure before deleting: %w", err) + } + } + // Same statement as postRepo.SoftDelete, inlined for transactionality. // Idempotent: zero rows (already deleted or never indexed) is success. if _, err := tx.ExecContext(ctx, @@ -206,6 +230,12 @@ func (c *PostEventConsumer) tombstoneRecordIfRevWins(ctx context.Context, uri, r return false, fmt.Errorf("failed to soft delete post: %w", err) } + if c.notifications != nil { + if err := c.notifications.RecordPostAuthorDeleteWithdrawalTx(ctx, tx, uri); err != nil { + return false, fmt.Errorf("record post author-delete withdrawal: %w", err) + } + } + if err := tx.Commit(); err != nil { return false, fmt.Errorf("failed to commit post delete transaction: %w", err) } @@ -248,10 +278,13 @@ func (c *PostEventConsumer) loadStoredPost(ctx context.Context, uri string) (sto // existing-row branch. Acceptance-triggered direct fetches insert missing rows // through insertAuthorPost instead. type postContentUpdate struct { - uri string - storedID int64 - rev string - cid string + uri string + // authorDID owns the incoming blobs, which are blocked when the post is + // removed even if the update is skipped. + authorDID string + storedID int64 + rev string + cid string title *string content *string @@ -268,10 +301,6 @@ type postContentUpdate struct { storedDeletedAt *time.Time storedIndexedAt time.Time timeUS int64 - - // authorDID owns the incoming blobs, which are blocked when the post is - // removed even if the update is skipped. - authorDID string } // applyPostContentUpdate runs the rev gate and the atomic content UPDATE. @@ -375,7 +404,7 @@ func (c *PostEventConsumer) applyPostContentUpdate(ctx context.Context, in postC // each feed stamps its own emission time — a pre-edit update replayed by the // lagging bsky feed carries a NEWER time_us than the edit it would regress. // Only rev, assigned by the repo itself, orders events across feeds. - tx, err := c.db.BeginTx(ctx, nil) + tx, err := c.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return false, fmt.Errorf("failed to begin transaction: %w", err) } @@ -394,6 +423,29 @@ func (c *PostEventConsumer) applyPostContentUpdate(ctx context.Context, in postC return false, nil } + var erased bool + var storedFacets string + var storedCreatedAt time.Time + if c.notifications != nil { + // Take the erasure lock before locking the post row, avoiding a cycle + // with account deletion that holds the erasure lock first. + erased, err = c.notifications.ErasureGateTx(ctx, tx, in.authorDID) + if err != nil { + return false, fmt.Errorf("check post author erasure before updating: %w", err) + } + err = tx.QueryRowContext(ctx, + `SELECT COALESCE(content_facets::text, ''), created_at + FROM posts WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, in.storedID, + ).Scan(&storedFacets, &storedCreatedAt) + if errors.Is(err, sql.ErrNoRows) { + log.Printf("Update event for post that was deleted between load and write: %s (skipping)", in.uri) + return false, nil + } + if err != nil { + return false, fmt.Errorf("read stored post before update: %w", err) + } + } + result, err := tx.ExecContext(ctx, updateQuery, in.storedID, in.cid, in.title, in.content, in.facets, in.embed, in.labels, @@ -420,6 +472,12 @@ func (c *PostEventConsumer) applyPostContentUpdate(ctx context.Context, in postC return false, nil } + if c.notifications != nil && !erased { + if err := c.writePostEditNotifications(ctx, tx, in, storedFacets, storedCreatedAt); err != nil { + return false, err + } + } + if err := commitMediaWrite(ctx, tx, in.uri, c.mediaReconciler, "post"); err != nil { return false, fmt.Errorf("failed to commit post update transaction: %w", err) } @@ -450,13 +508,15 @@ func parseBridgedAsOf(asOf, uri string) (time.Time, error) { // indexPostIfRevWins atomically indexes a post and reconciles comment counts. // This fixes the race condition where comments arrive before their parent post. +// When notifications are wired, the author's erasure gate is checked before +// touching the post row; an erased author's post is indexed without mentions. // // It reports whether the insert APPLIED: false means the rev gate refused the // event, or the row already existed. Callers that must not act on content they // did not write — the author-repo path, which opens an admission from the CID // it just indexed — read that flag rather than assuming the write happened. func (c *PostEventConsumer) indexPostIfRevWins(ctx context.Context, post *posts.Post, rev string) (bool, error) { - tx, err := c.db.BeginTx(ctx, nil) + tx, err := c.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return false, fmt.Errorf("failed to begin transaction: %w", err) } @@ -480,6 +540,14 @@ func (c *PostEventConsumer) indexPostIfRevWins(ctx context.Context, post *posts. return false, nil } + var erased bool + if c.notifications != nil { + erased, err = c.notifications.ErasureGateTx(ctx, tx, post.AuthorDID) + if err != nil { + return false, fmt.Errorf("check post author erasure before indexing: %w", err) + } + } + // 1. Insert the post (idempotent with RETURNING clause) var facetsJSON, embedJSON, labelsJSON sql.NullString @@ -581,6 +649,12 @@ func (c *PostEventConsumer) indexPostIfRevWins(ctx context.Context, post *posts. return false, fmt.Errorf("failed to reconcile comment_count for %s: %w", post.URI, reconcileErr) } + if c.notifications != nil && !erased { + if err := c.writePostCreateNotifications(ctx, tx, post); err != nil { + return false, err + } + } + // Commit transaction if err := commitMediaWrite(ctx, tx, post.URI, c.mediaReconciler, "post"); err != nil { return false, fmt.Errorf("failed to commit transaction: %w", err) @@ -589,6 +663,47 @@ func (c *PostEventConsumer) indexPostIfRevWins(ctx context.Context, post *posts. return true, nil } +// writePostCreateNotifications writes eligible mentions in the post insert transaction. +func (c *PostEventConsumer) writePostCreateNotifications(ctx context.Context, tx *sql.Tx, post *posts.Post) error { + var facetsJSON string + if post.ContentFacets != nil { + facetsJSON = *post.ContentFacets + } + intents, err := notifications.FanoutPostCreate(ctx, c.notifications.LookupsTx(tx), c.bridgeTrust, notifications.PostRecord{ + URI: post.URI, CID: post.CID, AuthorDID: post.AuthorDID, + CreatedAt: post.CreatedAt, FacetsJSON: facetsJSON, + }) + if err != nil { + return fmt.Errorf("compute post notifications: %w", err) + } + if err := c.notifications.ApplyTx(ctx, tx, intents); err != nil { + return fmt.Errorf("write post notifications: %w", err) + } + return nil +} + +// writePostEditNotifications writes new mentions in the post update transaction. +func (c *PostEventConsumer) writePostEditNotifications(ctx context.Context, tx *sql.Tx, in postContentUpdate, storedFacets string, storedCreatedAt time.Time) error { + var facetsJSON string + if in.facets.Valid { + facetsJSON = in.facets.String + } + // Missing time_us (<= 0) passes a zero EditEventTime, which falls back to + // index time for freshness; indexedAtForEvent substitutes wall clock instead. + editEventTime, _ := eventTime(in.timeUS) + intents, err := notifications.FanoutPostEdit(ctx, c.notifications.LookupsTx(tx), c.bridgeTrust, notifications.PostRecord{ + URI: in.uri, CID: in.cid, AuthorDID: in.authorDID, + CreatedAt: storedCreatedAt, FacetsJSON: facetsJSON, EditEventTime: editEventTime, + }, storedFacets) + if err != nil { + return fmt.Errorf("compute post edit notifications: %w", err) + } + if err := c.notifications.ApplyTx(ctx, tx, intents); err != nil { + return fmt.Errorf("write post edit notifications: %w", err) + } + return nil +} + // errValidationInfra marks an ingestion validation failure caused by an infrastructure fault // (e.g. a DB error while checking that the community or author exists) rather than a // policy rejection. The two are logged differently: policy rejections are security diff --git a/internal/atproto/jetstream/post_notification_edit_failure_test.go b/internal/atproto/jetstream/post_notification_edit_failure_test.go new file mode 100644 index 0000000..0d3dd24 --- /dev/null +++ b/internal/atproto/jetstream/post_notification_edit_failure_test.go @@ -0,0 +1,270 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type failingPostEditNotificationRepository struct { + notifications.Repository + failure error + failAt string + applyCalled bool + erasureCalled bool + intents []notifications.Intent +} + +func (repository *failingPostEditNotificationRepository) ApplyTx(ctx context.Context, tx *sql.Tx, intents []notifications.Intent) error { + repository.applyCalled = true + repository.intents = append(repository.intents, intents...) + if repository.failAt == "apply" { + return repository.failure + } + return repository.Repository.ApplyTx(ctx, tx, intents) +} + +func (repository *failingPostEditNotificationRepository) ErasureGateTx(ctx context.Context, tx *sql.Tx, did string) (bool, error) { + repository.erasureCalled = true + if repository.failAt == "erasure" { + return false, repository.failure + } + return repository.Repository.ErasureGateTx(ctx, tx, did) +} + +func postEditStoredFacets(t *testing.T, db *sql.DB, uri string) sql.NullString { + t.Helper() + var facets sql.NullString + require.NoError(t, db.QueryRow(`SELECT content_facets::text FROM posts WHERE uri = $1`, uri).Scan(&facets)) + return facets +} + +func TestPostNotificationEdit_RepositoryFailuresRollBackContentFacetsAndRev(t *testing.T) { + t.Parallel() + for _, failurePoint := range []string{"apply", "erasure"} { + t.Run(failurePoint, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + post := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 2) + createdAt := activatedCommentNotificationTime(t, db, ctx) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revisions := increasingTIDs(t, 2) + base := time.Now().UnixMicro() + original := postMentionConsumer(db, post, postgres.NewNotificationRepository(db)) + require.NoError(t, original.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revisions[0], + "bafyreiposteditfailurecreate", base, postMentionRecord(t, createdAt, handles[:1], recipients[:1])))) + before := readPostV2MechanismRow(t, db, uri) + beforeFacets := postEditStoredFacets(t, db, uri) + beforeRows := postMentionRows(t, db, uri) + require.Len(t, beforeRows, 1, "fixture: B has a mention before the edit") + require.Equal(t, revisions[0], readPostV2MechanismRev(t, db, uri)) + require.False(t, before.IndexedAt.After(time.UnixMicro(base+1_000_000)), "fixture: edit must pass the recency guard") + + injected := errors.New("injected post edit " + failurePoint + " failure") + failing := &failingPostEditNotificationRepository{ + Repository: postgres.NewNotificationRepository(db), failure: injected, failAt: failurePoint, + } + consumer := postMentionConsumer(db, post, failing) + err := consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revisions[1], + "bafyreiposteditfailureupdate", base+1_000_000, postMentionRecord(t, createdAt, handles, recipients))) + require.ErrorIs(t, err, injected, "failure must propagate from the winning edit") + require.True(t, failing.erasureCalled, "the edit must reach the author erasure gate") + require.Equal(t, failurePoint == "apply", failing.applyCalled, "only an edit past the gate reaches ApplyTx") + if failurePoint == "apply" { + require.Len(t, failing.intents, 1, "fixture: winning edit must offer E's mention to ApplyTx") + require.Equal(t, recipients[1], failing.intents[0].RecipientDID) + require.Equal(t, notifications.ReasonMention, failing.intents[0].Reason) + } + require.Equal(t, before, readPostV2MechanismRow(t, db, uri), "failed edit must roll back CID and content") + require.Equal(t, beforeFacets, postEditStoredFacets(t, db, uri), "failed edit must roll back facets") + require.Equal(t, revisions[0], readPostV2MechanismRev(t, db, uri), "failed edit must roll back the revision") + require.Equal(t, beforeRows, postMentionRows(t, db, uri), "failed edit must preserve B's original row") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, uri, recipients[1]), + "failed edit must not notify E") + }) + } +} + +func TestPostNotificationEdit_DeleteFirstWaitsForErasureBeforePostLock(t *testing.T) { + t.Parallel() + for _, operation := range []string{"update", "delete"} { + t.Run(operation, func(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // Register before the still-open fixture transaction. + deleteResults, consumerResults := make(chan error, 1), make(chan error, 1) + deleteStarted, deleteFinished := false, false + consumerStarted, consumerFinished := false, false + voteGroupResultsCleanup(t, ctx, deleteResults, &deleteStarted, &deleteFinished, "Delete(post author)") + voteGroupResultsCleanup(t, ctx, consumerResults, &consumerStarted, &consumerFinished, "HandleEvent(post "+operation+")") + + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, fixture.db, pv2Author) + deleteStarted = true + go func() { deleteResults <- postgres.NewUserRepository(fixture.db).Delete(ctx, pv2Author) }() + deleteProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "DELETE FROM users") + + var event *JetstreamEvent + if operation == "update" { + handles, recipients := postMentionRecipients(t, fixture.db, 1) + event = pv2Event(pv2Author, "update", fixture.key, fixture.revisions[2], + "bafyreiposteditdeletefirst", time.Now().Add(time.Second).UnixMicro(), + postMentionRecord(t, fixture.createdAt, + []string{fixture.handle, handles[0]}, []string{fixture.recipient, recipients[0]})) + } else { + event = pv2Event(pv2Author, "delete", fixture.key, fixture.revisions[2], + "", time.Now().UnixMicro(), nil) + } + consumerStarted = true + go func() { consumerResults <- fixture.consumer.HandleEvent(ctx, event) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + var waitingBeforePost bool + err := transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid NOT IN ($1, $2) + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND $1 = ANY(pg_blocking_pids(waiter.pid)) + AND NOT EXISTS (SELECT 1 FROM pg_locks postlock + WHERE postlock.pid = waiter.pid AND postlock.locktype = 'relation' + AND postlock.relation = 'posts'::regclass) + AND NOT EXISTS (SELECT 1 FROM pg_locks rowwait + WHERE rowwait.pid = waiter.pid AND NOT rowwait.granted + AND rowwait.locktype IN ('tuple', 'transactionid')) + )`, deleteProcessID, fixtureProcessID).Scan(&waitingBeforePost) + return waitingBeforePost, err + }, testkit.WithDescription("post %s waits on Delete's erasure advisory lock without locking posts", operation)) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, deleteResults, "Delete(post author)") + deleteFinished = true + commentErasureResult(t, ctx, consumerResults, "HandleEvent(post "+operation+")") + consumerFinished = true + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, pv2Author)) + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE actor_did = $1`, pv2Author), + "Delete-first must leave no notifications from the erased author") + }) + } +} + +func TestPostNotificationEdit_ErasedAuthorMarkerSkipsEditBeforeContentUpdate(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + ctx := context.Background() + handles, recipients := postMentionRecipients(t, fixture.db, 1) + before := readPostV2MechanismRow(t, fixture.db, fixture.uri) + beforeFacets := postEditStoredFacets(t, fixture.db, fixture.uri) + beforeRows := postMentionRows(t, fixture.db, fixture.uri) + require.Len(t, beforeRows, 1, "fixture: B was notified before the marker") + markAccountDeleted(t, fixture.db, pv2Author) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM users WHERE did = $1`, pv2Author), + "fixture: author user row remains, so only the erasure marker causes the skip") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM posts WHERE uri = $1`, fixture.uri), + "fixture: post row remains") + require.NoError(t, fixture.consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", fixture.key, + fixture.revisions[2], "bafyreiposteditmarkedauthor", time.Now().Add(time.Second).UnixMicro(), + postMentionRecord(t, fixture.createdAt, + []string{fixture.handle, handles[0]}, []string{fixture.recipient, recipients[0]})))) + require.Equal(t, before, readPostV2MechanismRow(t, fixture.db, fixture.uri), + "the author-post precheck skips the entire erased-author event before the edit transaction") + require.Equal(t, beforeFacets, postEditStoredFacets(t, fixture.db, fixture.uri)) + require.Equal(t, fixture.revisions[1], readPostV2MechanismRev(t, fixture.db, fixture.uri)) + require.Equal(t, beforeRows, postMentionRows(t, fixture.db, fixture.uri)) + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, fixture.uri, recipients[0]), + "an erased author's skipped edit must not notify E") +} + +func TestPostNotificationEdit_ErasureCommittedDuringGateSuppressesMention(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + handles, recipients := postMentionRecipients(t, fixture.db, 1) + beforeRows := postMentionRows(t, fixture.db, fixture.uri) + require.Len(t, beforeRows, 1, "fixture: B has a mention before E is added") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, pv2Author)) + const editCID = "bafyreiposteditracedmark" + event := pv2Event(pv2Author, "update", fixture.key, fixture.revisions[2], editCID, + time.Now().Add(time.Second).UnixMicro(), postMentionRecord(t, fixture.createdAt, + []string{fixture.handle, handles[0]}, []string{fixture.recipient, recipients[0]})) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // Register before the fixture transaction, so its rollback releases the lock first. + results := make(chan error, 1) + started, finished := false, false + voteGroupResultsCleanup(t, ctx, results, &started, &finished, "HandleEvent(post edit after erasure marker)") + + connection, err := fixture.db.Conn(ctx) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, connection.Close()) }) + transaction, err := connection.BeginTx(context.Background(), nil) + require.NoError(t, err) + t.Cleanup(func() { + rollbackError := transaction.Rollback() + require.True(t, rollbackError == nil || errors.Is(rollbackError, sql.ErrTxDone), + "rolling back erasure-marker fixture transaction: %v", rollbackError) + }) + var fixtureProcessID int + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&fixtureProcessID)) + _, err = transaction.ExecContext(ctx, "SELECT pg_advisory_xact_lock("+postgres.ErasureLockKeySQL+")", pv2Author) + require.NoError(t, err) + _, err = transaction.ExecContext(ctx, `INSERT INTO deleted_accounts (did, deleted_at) VALUES ($1, NOW())`, pv2Author) + require.NoError(t, err) + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, pv2Author), + "the consumer precheck must not see the uncommitted erasure marker") + + started = true + go func() { results <- fixture.consumer.HandleEvent(ctx, event) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + var waitingAtGate bool + err := transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + JOIN pg_stat_activity activity ON activity.pid = waiter.pid + WHERE holder.pid = $1 AND waiter.pid <> $1 + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND $1 = ANY(pg_blocking_pids(waiter.pid)) + AND activity.query ILIKE '%pg_advisory_xact_lock_shared%' + AND NOT EXISTS (SELECT 1 FROM pg_locks postlock + WHERE postlock.pid = waiter.pid AND postlock.locktype = 'relation' + AND postlock.relation = 'posts'::regclass) + )`, fixtureProcessID).Scan(&waitingAtGate) + return waitingAtGate, err + }, testkit.WithDescription("post edit passes the marker precheck and waits at the in-transaction erasure gate")) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(post edit after erasure marker)") + finished = true + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, pv2Author)) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM users WHERE did = $1`, pv2Author)) + row := readPostV2MechanismRow(t, fixture.db, fixture.uri) + require.Equal(t, editCID, row.CID, "edit must apply after the in-transaction gate sees erasure") + require.Equal(t, "mentions @"+fixture.handle+" @"+handles[0], row.Content) + requirePostMentionFacets(t, fixture.db, fixture.uri, fixture.recipient, recipients[0]) + require.Equal(t, fixture.revisions[2], readPostV2MechanismRev(t, fixture.db, fixture.uri)) + require.Equal(t, beforeRows, postMentionRows(t, fixture.db, fixture.uri), "erased-author edit must not add or retract mentions") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, fixture.uri, recipients[0]), + "the in-transaction erasure gate must suppress E's mention") +} diff --git a/internal/atproto/jetstream/post_notification_edit_guards_test.go b/internal/atproto/jetstream/post_notification_edit_guards_test.go new file mode 100644 index 0000000..ee21146 --- /dev/null +++ b/internal/atproto/jetstream/post_notification_edit_guards_test.go @@ -0,0 +1,215 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +// Hold the post row while the consumer uses a separate pool connection. The +// fixture transaction's backend also observes the consumer's lock wait. +func postEditRowTransaction(t *testing.T, ctx context.Context, db *sql.DB, uri string) (*sql.Tx, int) { + t.Helper() + connection, err := db.Conn(ctx) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, connection.Close()) }) + transaction, err := connection.BeginTx(context.Background(), nil) + require.NoError(t, err) + t.Cleanup(func() { + rollbackError := transaction.Rollback() + require.True(t, rollbackError == nil || errors.Is(rollbackError, sql.ErrTxDone), + "rolling back post-row fixture transaction: %v", rollbackError) + }) + var processID int + var lockedURI string + require.NoError(t, transaction.QueryRowContext(ctx, + `SELECT pg_backend_pid(), uri FROM posts WHERE uri = $1 FOR UPDATE`, uri).Scan(&processID, &lockedURI)) + require.Equal(t, uri, lockedURI) + return transaction, processID +} + +func TestPostNotificationEdit_NonqualifyingUpdatesPreserveNotifications(t *testing.T) { + t.Parallel() + for _, name := range []string{"stale revision", "recency guard", "already soft deleted", "concurrent delete", "concurrent recency advancement"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 2) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revisions := increasingTIDs(t, 3) + base := time.Now().UnixMicro() + const createCID = "bafyreiposteditguardscreate" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revisions[1], + createCID, base, postMentionRecord(t, createdAt, handles[:1], recipients[:1])))) + requirePostMentionFacets(t, db, uri, recipients[0]) + before := postMentionRows(t, db, uri) + require.Len(t, before, 1, "fixture: original post must notify B") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri)) + require.Equal(t, revisions[1], readPostV2MechanismRev(t, db, uri)) + var indexedAt time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT indexed_at FROM posts WHERE uri = $1`, uri).Scan(&indexedAt)) + event := pv2Event(pv2Author, "update", key, revisions[2], "bafyreiposteditguardsskipped", + base+1_000_000, postMentionRecord(t, createdAt, handles, recipients)) + + switch name { + case "stale revision": + event.Commit.Rev = revisions[0] + require.Less(t, event.Commit.Rev, revisions[1]) + require.NoError(t, consumer.HandleEvent(ctx, event)) + case "recency guard": + event.TimeUS = indexedAt.Add(-time.Second).UnixMicro() + require.NoError(t, consumer.HandleEvent(ctx, event)) + case "already soft deleted": + _, err := db.ExecContext(ctx, `UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, uri) + require.NoError(t, err, "fixture: leave the original notification present on the deleted row") + require.NoError(t, consumer.HandleEvent(ctx, event)) + case "concurrent delete", "concurrent recency advancement": + results := make(chan error, 1) + started, finished := false, false + t.Cleanup(func() { + if started && !finished { + commentErasureResult(t, ctx, results, "HandleEvent(post edit) after fixture rollback") + } + }) + transaction, processID := postEditRowTransaction(t, ctx, db, uri) + if name == "concurrent delete" { + _, err := transaction.ExecContext(ctx, `UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, uri) + require.NoError(t, err) + } else { + _, err := transaction.ExecContext(ctx, `UPDATE posts SET indexed_at = $1 WHERE uri = $2`, + time.UnixMicro(event.TimeUS).Add(time.Hour), uri) + require.NoError(t, err) + } + started = true + go func() { results <- consumer.HandleEvent(ctx, event) }() + commentErasureBlockedByFixture(t, ctx, transaction, processID, "FROM posts WHERE id") + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(post edit)") + finished = true + if name == "concurrent recency advancement" { + var content string + require.NoError(t, db.QueryRowContext(ctx, `SELECT content FROM posts WHERE uri = $1`, uri).Scan(&content)) + require.Equal(t, "mentions @"+handles[0], content, "zero-row edit must preserve original content") + } + default: + t.Fatalf("unhandled guard case %q", name) + } + require.Equal(t, before, postMentionRows(t, db, uri), "skipped edit must preserve notification IDs and CIDs") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri), + "skipped edit must not add any other notification") + require.Equal(t, revisions[1], readPostV2MechanismRev(t, db, uri), "skipped edit must not advance the rev") + require.Equal(t, createCID, readPostV2MechanismRow(t, db, uri).CID) + }) + } +} + +func TestPostNotificationEdit_CapCountsExistingEightMentionRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 12) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revisions := increasingTIDs(t, 2) + base := time.Now().UnixMicro() + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revisions[0], + "bafyreiposteditcapcreate", base, postMentionRecord(t, createdAt, handles[:8], recipients[:8])))) + before := postMentionRows(t, db, uri) + require.Len(t, before, 8, "fixture: eight eligible recipients must already hold rows") + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revisions[1], + "bafyreiposteditcapupdate", base+1_000_000, postMentionRecord(t, createdAt, handles, recipients)))) + requirePostMentionFacets(t, db, uri, recipients...) + after := postMentionRows(t, db, uri) + require.Len(t, after, 10, "the cap applies across create and edit, not independently per edit") + require.Equal(t, before, after[:8], "original mention rows must survive the edit") + for index := 8; index < 10; index++ { + require.Equal(t, recipients[index], after[index].recipient, "facet order chooses the two available slots") + require.Equal(t, "bafyreiposteditcapupdate", after[index].cid) + } + for _, recipient := range recipients[10:] { + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, uri, recipient)) + } + require.Equal(t, revisions[1], readPostV2MechanismRev(t, db, uri), "fixture: the edit must win rather than silently skip") +} + +func TestPostNotificationEdit_DiffAndCapUseLockedStoredFacets(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + // X, Y, Z are the incoming facets. Nine distinct pre-existing recipients + // occupy the budget after the fixture transaction commits. + handles, recipients := postMentionRecipients(t, db, 12) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revisions := increasingTIDs(t, 2) + base := time.Now().UnixMicro() + create := pv2Record(pv2Community, "Locked post", "No mentions yet") + create["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revisions[0], + "bafyreiposteditlockedcreate", base, create))) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri)) + event := pv2Event(pv2Author, "update", key, revisions[1], "bafyreiposteditlockedupdate", + base+1_000_000, postMentionRecord(t, createdAt, handles[:3], recipients[:3])) + xFacetRecord := postMentionRecord(t, createdAt, handles[:1], recipients[:1]) + xFacets, err := json.Marshal(xFacetRecord["facets"]) + require.NoError(t, err) + results := make(chan error, 1) + started, finished := false, false + t.Cleanup(func() { + if started && !finished { + commentErasureResult(t, ctx, results, "HandleEvent(post edit) after fixture rollback") + } + }) + transaction, processID := postEditRowTransaction(t, ctx, db, uri) + _, err = transaction.ExecContext(ctx, `UPDATE posts SET content_facets = $1::jsonb WHERE uri = $2`, string(xFacets), uri) + require.NoError(t, err) + for _, recipient := range recipients[3:] { + _, err = transaction.ExecContext(ctx, `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, root_post_uri, record_created_at) + VALUES ($1, 'mention', $2, $3, $4, $2, NOW())`, recipient, uri, + "bafyreiposteditlockedcreate", pv2Author) + require.NoError(t, err) + } + started = true + go func() { results <- consumer.HandleEvent(ctx, event) }() + commentErasureBlockedByFixture(t, ctx, transaction, processID, "FROM posts WHERE id") + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(post edit)") + finished = true + require.Equal(t, revisions[1], readPostV2MechanismRev(t, db, uri), "fixture: edit must apply") + requirePostMentionFacets(t, db, uri, recipients[:3]...) + rows := postMentionRows(t, db, uri) + require.Len(t, rows, 10, "nine committed rows leave exactly one edit slot") + for _, recipient := range recipients[3:] { + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, uri, recipient)) + } + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, uri, recipients[0]), + "X was already in facets committed under the post lock") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2 AND record_cid = $3`, + uri, recipients[1], "bafyreiposteditlockedupdate"), "Y gets the one available slot") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, uri, recipients[2]), + "Z follows Y in facet order and exceeds the cap") +} diff --git a/internal/atproto/jetstream/post_notification_edit_test.go b/internal/atproto/jetstream/post_notification_edit_test.go new file mode 100644 index 0000000..02beb4a --- /dev/null +++ b/internal/atproto/jetstream/post_notification_edit_test.go @@ -0,0 +1,373 @@ +//go:build integration + +package jetstream + +import ( + "context" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func TestPostNotificationEdit_AcceptanceAddKeepRemoveThenDelete(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 2) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 4) + base := time.Now().UnixMicro() + const ( + createCID = "bafyreiposteditacceptancecreate" + addCID = "bafyreiposteditacceptanceadd" + removeCID = "bafyreiposteditacceptanceremove" + ) + + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + createCID, base, postMentionRecord(t, createdAt, handles[:1], recipients[:1])))) + _, _, indexedCID, _, deletedAt := readPV2Post(t, db, uri) + require.Equal(t, createCID, indexedCID) + require.Nil(t, deletedAt) + requirePostMentionFacets(t, db, uri, recipients[0]) + original := postMentionRows(t, db, uri) + require.Len(t, original, 1, "fixture: B must receive a mention on create") + require.Equal(t, recipients[0], original[0].recipient) + require.Equal(t, createCID, original[0].cid) + var storedCreatedAt time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT created_at FROM posts WHERE uri = $1`, uri).Scan(&storedCreatedAt)) + + // An edit's incoming createdAt must not replace the indexed post's original time. + editCreatedAt := storedCreatedAt.Add(30 * time.Second).UTC().Format(time.RFC3339Nano) + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + addCID, base+1_000_000, postMentionRecord(t, editCreatedAt, handles, recipients)))) + _, _, indexedCID, _, _ = readPV2Post(t, db, uri) + require.Equal(t, addCID, indexedCID, "fixture: the winning edit must reach the post row") + requirePostMentionFacets(t, db, uri, recipients...) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, uri, recipients[1]), + "the edit must add exactly one mention row for E") + var editCID string + var recordCreatedAt time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT record_cid, record_created_at FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, uri, recipients[1]). + Scan(&editCID, &recordCreatedAt)) + require.Equal(t, addCID, editCID) + require.True(t, recordCreatedAt.Equal(storedCreatedAt), + "E's mention must carry the stored post created_at, not the edit's incoming createdAt") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND id = $3 AND record_cid = $4`, + uri, recipients[0], original[0].id, createCID), "keeping B must not re-notify B") + require.Len(t, postMentionRows(t, db, uri), 2) + + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[2], + removeCID, base+2_000_000, postMentionRecord(t, editCreatedAt, handles[1:], recipients[1:])))) + _, _, indexedCID, _, _ = readPV2Post(t, db, uri) + require.Equal(t, removeCID, indexedCID) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND id = $3 AND record_cid = $4`, + uri, recipients[0], original[0].id, createCID), "removing B must retain B's original mention") + require.Len(t, postMentionRows(t, db, uri), 2, "removing B must not retract or duplicate mentions") + + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "delete", key, revs[3], + "", base+3_000_000, nil))) + _, _, _, _, deletedAt = readPV2Post(t, db, uri) + require.NotNil(t, deletedAt, "the winning delete must tombstone the post") + require.Equal(t, 2, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri), + "the post tombstone must keep both notification rows") +} + +func TestPostNotificationEdit_OldStoredPostNotifiesOnFreshEdit(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + now := time.Now().UTC().Truncate(time.Microsecond) + createdAt := now.Add(-10 * 24 * time.Hour) + _, err := db.ExecContext(ctx, `UPDATE notification_activation SET activated_at = $1`, now.Add(-11*24*time.Hour)) + require.NoError(t, err) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 2) + create := pv2Record(pv2Community, "Old post", "No mentions yet") + create["createdAt"] = createdAt.Format(time.RFC3339Nano) + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + "bafyreioldpostcreate", now.Add(-2*time.Second).UnixMicro(), create))) + var storedCreatedAt time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT created_at FROM posts WHERE uri = $1`, uri).Scan(&storedCreatedAt)) + require.True(t, storedCreatedAt.Equal(createdAt), "fixture: the stored post must be more than seven days old") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri)) + const editCID = "bafyreioldpostfreshedit" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + editCID, now.Add(time.Second).UnixMicro(), postMentionRecord(t, createdAt.Format(time.RFC3339Nano), handles, recipients)))) + _, _, indexedCID, _, _ := readPV2Post(t, db, uri) + require.Equal(t, editCID, indexedCID, "fixture: the fresh edit must apply despite the old createdAt") + requirePostMentionFacets(t, db, uri, recipients[0]) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, uri, recipients[0], editCID), + "an old post edited now must notify its newly mentioned recipient") +} + +func TestPostNotificationEdit_OldEventAppliesContentWithoutMention(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 2) + create := pv2Record(pv2Community, "Old event post", "No mentions yet") + create["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + "bafyreipostoldeventcreate", time.Now().UnixMicro(), create))) + var oldTime, databaseNow time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT now(), now() - INTERVAL '8 days'`).Scan(&databaseNow, &oldTime)) + require.True(t, oldTime.Before(databaseNow.Add(-7*24*time.Hour)), "fixture: edit event is older than the freshness window") + _, err := db.ExecContext(ctx, `UPDATE posts SET indexed_at = $1 WHERE uri = $2`, oldTime.Add(-time.Hour), uri) + require.NoError(t, err) + var storedIndexedAt time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT indexed_at FROM posts WHERE uri = $1`, uri).Scan(&storedIndexedAt)) + require.True(t, storedIndexedAt.Before(oldTime), "fixture: old edit must pass the recency guard") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri)) + const editCID = "bafyreipostoldeventedit" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + editCID, oldTime.UnixMicro(), postMentionRecord(t, createdAt, handles, recipients)))) + row := readPostV2MechanismRow(t, db, uri) + require.Equal(t, editCID, row.CID, "old event must still replace the post CID") + require.Equal(t, "mentions @"+handles[0], row.Content, "old event must still replace the post content") + require.Equal(t, revs[1], readPostV2MechanismRev(t, db, uri), "old edit must advance the revision") + requirePostMentionFacets(t, db, uri, recipients[0]) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, uri, recipients[0]), + "an edit event eight days old must not notify its new mention") +} + +func TestPostNotificationEdit_ZeroEventTimeNotifiesAddedMention(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 2) + create := pv2Record(pv2Community, "Zero time post", "No mentions yet") + create["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + "bafyreipostzerotimecreate", time.Now().UnixMicro(), create))) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri)) + const editCID = "bafyreipostzerotimeedit" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + editCID, 0, postMentionRecord(t, createdAt, handles, recipients)))) + require.Equal(t, editCID, readPostV2MechanismRow(t, db, uri).CID, "zero-time edit must apply") + require.Equal(t, revs[1], readPostV2MechanismRev(t, db, uri)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, uri, recipients[0], editCID), + "missing Jetstream time falls back to index-time freshness, not the Unix epoch") +} + +func TestPostNotificationEdit_StoredPreActivationPostStaysSilentDespiteNewIncomingTime(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + now := time.Now().UTC().Truncate(time.Microsecond) + storedCreatedAt := now.Add(-2 * time.Hour) + _, err := db.ExecContext(ctx, `UPDATE notification_activation SET activated_at = $1`, now.Add(-time.Hour)) + require.NoError(t, err) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 2) + create := pv2Record(pv2Community, "Pre-activation post", "No mentions yet") + create["createdAt"] = storedCreatedAt.Format(time.RFC3339Nano) + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + "bafyreipreactivationpost", now.UnixMicro(), create))) + var stored time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT created_at FROM posts WHERE uri = $1`, uri).Scan(&stored)) + require.True(t, stored.Equal(storedCreatedAt), "fixture: post must predate activation") + const editCID = "bafyreipreactivationedit" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + editCID, now.Add(time.Second).UnixMicro(), postMentionRecord(t, now.Format(time.RFC3339Nano), handles, recipients)))) + _, _, indexedCID, _, _ := readPV2Post(t, db, uri) + require.Equal(t, editCID, indexedCID, "fixture: the edit must apply even though notification eligibility fails") + requirePostMentionFacets(t, db, uri, recipients[0]) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri), + "a newly stamped edit cannot activate a post created before notifications started") +} + +func TestPostNotificationEdit_StoredActivatedPostNotifiesDespiteOldIncomingTime(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + storedCreatedAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 2) + create := pv2Record(pv2Community, "Activated post", "No mentions yet") + create["createdAt"] = storedCreatedAt + base := time.Now().UnixMicro() + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + "bafyreiactivatedpost", base, create))) + var stored, activation time.Time + require.NoError(t, db.QueryRowContext(ctx, `SELECT created_at FROM posts WHERE uri = $1`, uri).Scan(&stored)) + require.NoError(t, db.QueryRowContext(ctx, `SELECT activated_at FROM notification_activation`).Scan(&activation)) + require.True(t, stored.After(activation), "fixture: stored post creation must be after activation") + const editCID = "bafyreiactivatedpostedit" + beforeActivation := activation.Add(-time.Hour).UTC().Format(time.RFC3339Nano) + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + editCID, base+1_000_000, postMentionRecord(t, beforeActivation, handles, recipients)))) + _, _, indexedCID, _, _ := readPV2Post(t, db, uri) + require.Equal(t, editCID, indexedCID, "fixture: the edit with an old incoming createdAt must apply") + requirePostMentionFacets(t, db, uri, recipients[0]) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, uri, recipients[0], editCID), + "activation must use the stored post created_at, not the incoming edit timestamp") +} + +func TestPostNotificationEdit_PreviouslyIneligibleStoredFacetIsNotNew(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + prefix := testkit.UniqueID(t) + bHandle := prefix + "b.test" + bDID := "did:plc:" + prefix + "b" + eHandles, eRecipients := postMentionRecipients(t, db, 1) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 2) + base := time.Now().UnixMicro() + require.Zero(t, countRows(t, db, `SELECT count(*) FROM users WHERE did = $1`, bDID), + "fixture: B must be unindexed at creation") + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + "bafyreistoredunindexedb", base, postMentionRecord(t, createdAt, []string{bHandle}, []string{bDID})))) + requirePostMentionFacets(t, db, uri, bDID) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri), + "fixture: B must not receive a mention before being indexed") + insertBridgedUserOnPDS(t, db, bDID, bHandle, bridgedTestNativePDS) + const editCID = "bafyreiunindexedbedited" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + editCID, base+1_000_000, postMentionRecord(t, createdAt, + []string{bHandle, eHandles[0]}, []string{bDID, eRecipients[0]})))) + _, _, indexedCID, _, _ := readPV2Post(t, db, uri) + require.Equal(t, editCID, indexedCID, "fixture: the edit must apply after B is indexed") + requirePostMentionFacets(t, db, uri, bDID, eRecipients[0]) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, uri, eRecipients[0], editCID), + "only newly mentioned E gets a mention on the edit") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, uri, bDID), + "B was in stored facets already, despite having no row from the create") +} + +func TestPostNotificationEdit_UpdateGateRequestsReadCommittedExplicitly(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 2) + base := time.Now().UnixMicro() + create := pv2Record(pv2Community, "Isolation post", "No mentions yet") + create["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + "bafyreiposteditisolationcreate", base, create))) + var database string + require.NoError(t, db.QueryRowContext(ctx, `SELECT current_database()`).Scan(&database)) + _, err := db.ExecContext(ctx, + "ALTER DATABASE "+pq.QuoteIdentifier(database)+" SET default_transaction_isolation = 'repeatable read'") + require.NoError(t, err) + db.SetMaxIdleConns(0) // New sessions must observe the changed database default. + control, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + var defaultIsolation string + require.NoError(t, control.QueryRowContext(ctx, `SELECT current_setting('transaction_isolation')`).Scan(&defaultIsolation)) + require.NoError(t, control.Rollback()) + require.Equal(t, "repeatable read", defaultIsolation, + "control: a transaction without isolation options must inherit the database default") + const editCID = "bafyreiposteditisolationupdate" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + editCID, base+1_000_000, postMentionRecord(t, createdAt, handles, recipients))), + "an edit's erasure gate needs an explicitly READ COMMITTED transaction") + _, _, indexedCID, _, _ := readPV2Post(t, db, uri) + require.Equal(t, editCID, indexedCID, "fixture: the edit must apply") + requirePostMentionFacets(t, db, uri, recipients[0]) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, uri, recipients[0], editCID), + "the edit must notify even if a nil-options transaction would inherit REPEATABLE READ") +} + +// A mention removed by one edit and re-added by a later one counts as added, +// but its recipient already holds a mention row, so it takes no budget slot. +func TestPostNotificationEdit_ReaddNearCapTakesNoBudgetSlot(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 11) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 3) + base := time.Now().UnixMicro() + const ( + createCID = "bafyreipostreaddcapcreate" + removeCID = "bafyreipostreaddcapremove" + readdCID = "bafyreipostreaddcapreadd" + ) + readded, fresh, overCap := recipients[8], recipients[9], recipients[10] + + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + createCID, base, postMentionRecord(t, createdAt, handles[:9], recipients[:9])))) + require.Len(t, postMentionRows(t, db, uri), 9, "fixture: the create must notify all nine mentions") + + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[1], + removeCID, base+1_000_000, postMentionRecord(t, createdAt, handles[:8], recipients[:8])))) + _, _, indexedCID, _, _ := readPV2Post(t, db, uri) + require.Equal(t, removeCID, indexedCID, "fixture: the removing edit must reach the post row") + require.Len(t, postMentionRows(t, db, uri), 9, "removing a mention must keep its notification row") + + // Facet order puts the re-added recipient ahead of the fresh one, so a + // re-add that consumed budget would take the last slot. + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revs[2], + readdCID, base+2_000_000, postMentionRecord(t, createdAt, handles, recipients)))) + _, _, indexedCID, _, _ = readPV2Post(t, db, uri) + require.Equal(t, readdCID, indexedCID, "fixture: the re-adding edit must reach the post row") + requirePostMentionFacets(t, db, uri, readded, fresh, overCap) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, uri, readded, createCID), + "the re-added recipient keeps exactly its original mention row") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention' AND record_cid = $3`, uri, fresh, readdCID), + "the newly mentioned recipient takes the tenth slot") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2`, uri, overCap), + "a mention past the per-record cap gets no notification") + require.Len(t, postMentionRows(t, db, uri), 10, "the record holds at most ten mention notifications") +} diff --git a/internal/atproto/jetstream/post_notification_erasure_test.go b/internal/atproto/jetstream/post_notification_erasure_test.go new file mode 100644 index 0000000..7455044 --- /dev/null +++ b/internal/atproto/jetstream/post_notification_erasure_test.go @@ -0,0 +1,100 @@ +//go:build integration + +package jetstream + +import ( + "context" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func TestPostConsumer_NotificationErasure_DeleteFirstWaitsBeforeContent(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // Register before the fixture transaction, so cleanup rolls it back first. + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + event := pv2Event(pv2Author, "create", testkit.TID(), testkit.TID(), + "bafyreiposterasure", time.Now().UnixMicro(), postMentionRecord(t, createdAt, handles, recipients)) + deleteResults, consumerResults := make(chan error, 1), make(chan error, 1) + deleteStarted, deleteFinished := false, false + consumerStarted, consumerFinished := false, false + voteGroupResultsCleanup(t, ctx, deleteResults, &deleteStarted, &deleteFinished, "Delete(post author)") + voteGroupResultsCleanup(t, ctx, consumerResults, &consumerStarted, &consumerFinished, "HandleEvent(post)") + + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, db, pv2Author) + deleteStarted = true + go func() { deleteResults <- postgres.NewUserRepository(db).Delete(ctx, pv2Author) }() + deleteProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "DELETE FROM users") + + consumerStarted = true + go func() { consumerResults <- consumer.HandleEvent(ctx, event) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + var waitingBeforeContent bool + err := transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid NOT IN ($1, $2) + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND $1 = ANY(pg_blocking_pids(waiter.pid)) + AND NOT EXISTS (SELECT 1 FROM pg_locks content + WHERE content.pid = waiter.pid AND content.locktype = 'relation' + AND content.relation = 'posts'::regclass) + )`, deleteProcessID, fixtureProcessID).Scan(&waitingBeforeContent) + return waitingBeforeContent, err + }, testkit.WithDescription("post consumer waits on Delete's erasure advisory lock before touching posts")) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, deleteResults, "Delete(post author)") + deleteFinished = true + commentErasureResult(t, ctx, consumerResults, "HandleEvent(post)") + consumerFinished = true + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE actor_did = $1`, pv2Author), + "Delete-first post ingestion must leave no notifications from the erased author") +} + +func TestPostConsumer_NotificationErasure_GateRequestsReadCommittedExplicitly(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + ctx := context.Background() + createdAt := activatedCommentNotificationTime(t, db, ctx) + var database string + require.NoError(t, db.QueryRowContext(ctx, `SELECT current_database()`).Scan(&database)) + _, err := db.ExecContext(ctx, + "ALTER DATABASE "+pq.QuoteIdentifier(database)+" SET default_transaction_isolation = 'repeatable read'") + require.NoError(t, err) + db.SetMaxIdleConns(0) // New sessions must observe the changed database default. + control, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + var defaultIsolation string + require.NoError(t, control.QueryRowContext(ctx, `SELECT current_setting('transaction_isolation')`).Scan(&defaultIsolation)) + require.NoError(t, control.Rollback()) + require.Equal(t, "repeatable read", defaultIsolation, + "control: a transaction without isolation options must inherit the database default") + + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, testkit.TID(), + "bafyreipostisolation", time.Now().UnixMicro(), postMentionRecord(t, createdAt, handles, recipients)))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE reason = 'mention' AND record_uri = $1 AND recipient_did = $2 AND actor_did = $3`, + uri, recipients[0], pv2Author), "the post mention must pass the erasure gate under READ COMMITTED") +} diff --git a/internal/atproto/jetstream/post_notification_insert_test.go b/internal/atproto/jetstream/post_notification_insert_test.go new file mode 100644 index 0000000..091bf32 --- /dev/null +++ b/internal/atproto/jetstream/post_notification_insert_test.go @@ -0,0 +1,407 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/crypto/credentialcipher/credentialciphertest" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func postMentionConsumer(db *sql.DB, fixture pv2Fixture, repository notifications.Repository, options ...PostEventConsumerOption) *PostEventConsumer { + options = append([]PostEventConsumerOption{ + WithAdmissions(fixture.admissions), + WithDeletedAccounts(postgres.NewDeletedAccountRepository(db)), + WithPostNotifications(repository), + }, options...) + return NewPostEventConsumer( + postgres.NewPostRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), + fixture.users, db, options..., + ) +} + +func postMentionRecord(t *testing.T, createdAt string, handles, recipients []string) map[string]interface{} { + t.Helper() + require.Len(t, handles, len(recipients)) + content := "mentions @" + strings.Join(handles, " @") + record := pv2Record(pv2Community, "Post mentioning users", content) + record["createdAt"] = createdAt + facets := make([]interface{}, len(recipients)) + for index := range recipients { + facets[index] = commentMentionFacet(t, content, handles[index], recipients[index]) + } + record["facets"] = facets + return record +} + +func postMentionRecipients(t *testing.T, db *sql.DB, count int) ([]string, []string) { + t.Helper() + prefix := testkit.UniqueID(t) + handles := make([]string, count) + recipients := make([]string, count) + for index := range recipients { + handles[index] = fmt.Sprintf("%sperson%02d.test", prefix, index) + recipients[index] = fmt.Sprintf("did:plc:%sperson%02d", prefix, index) + insertBridgedUserOnPDS(t, db, recipients[index], handles[index], bridgedTestNativePDS) + } + return handles, recipients +} + +type postMentionRow struct { + id int64 + recipient string + cid string +} + +func postMentionRows(t *testing.T, db *sql.DB, uri string) []postMentionRow { + t.Helper() + rows, err := db.Query(`SELECT id, recipient_did, record_cid FROM notifications + WHERE record_uri = $1 AND reason = 'mention' ORDER BY id`, uri) + require.NoError(t, err) + defer rows.Close() + var result []postMentionRow + for rows.Next() { + var row postMentionRow + require.NoError(t, rows.Scan(&row.id, &row.recipient, &row.cid)) + result = append(result, row) + } + require.NoError(t, rows.Err()) + return result +} + +func requirePostMentionFacets(t *testing.T, db *sql.DB, uri string, recipients ...string) { + t.Helper() + var facets sql.NullString + require.NoError(t, db.QueryRow(`SELECT content_facets FROM posts WHERE uri = $1`, uri).Scan(&facets)) + require.True(t, facets.Valid, "fixture: post mention facets must be indexed") + for _, recipient := range recipients { + require.Contains(t, facets.String, recipient, "fixture: mention must survive post indexing") + } +} + +func TestPostNotificationInsert_PendingPostMentionsBeforeAcceptance(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + const cid = "bafyreiinsertpending" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, testkit.TID(), cid, + time.Now().UnixMicro(), postMentionRecord(t, createdAt, handles, recipients)))) + _, _, indexedCID, _, deletedAt := readPV2Post(t, db, uri) + require.Equal(t, cid, indexedCID) + require.Nil(t, deletedAt) + requirePostMentionFacets(t, db, uri, recipients...) + admission, err := fixture.admissions.Get(ctx, pv2Community, uri) + require.NoError(t, err) + require.Equal(t, posts.AdmissionStatusPending, admission.Status, "fixture: no acceptance has arrived") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri), + "a pending post must notify its mentioned recipient") + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 + AND recipient_did = $2 AND reason = 'mention' AND actor_did = $3 AND record_cid = $4 + AND subject_uri IS NULL AND root_post_uri = $1`, uri, recipients[0], pv2Author, cid)) +} + +func TestPostNotificationInsert_DirectFetchFirstAndJetstreamReplay(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newRealRepoFixture(t, db) + handles, recipients := postMentionRecipients(t, db, 2) + createdAt := activatedCommentNotificationTime(t, db, ctx) + postRecord := postMentionRecord(t, createdAt, handles, recipients) + postRecord["community"] = accCommunity + record := fixture.author.CreateRecord(t, PostV2Collection, postRecord) + consumer := newRealPostMentionConsumer(db, fixture, postgres.NewNotificationRepository(db), nil) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, record.URI), + "fixture: direct fetch must be the first indexing path") + require.NoError(t, consumer.HandleEvent(ctx, acceptanceEvent(accCommunity, record.URI, record.CID, + testkit.TID(), time.Now().UnixMicro()))) + _, communityDID, indexedCID, _, deletedAt := readPV2Post(t, db, record.URI) + require.Equal(t, accCommunity, communityDID) + require.Equal(t, record.CID, indexedCID) + require.Nil(t, deletedAt) + requirePostMentionFacets(t, db, record.URI, recipients...) + before := postMentionRows(t, db, record.URI) + require.Len(t, before, 2, "direct-fetch insertion must notify both mentioned users") + for index, row := range before { + require.Equal(t, recipients[index], row.recipient) + require.Equal(t, record.CID, row.cid) + } + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(fixture.author.DID, "create", record.RKey, + testkit.TID(), record.CID, time.Now().UnixMicro(), postRecord))) + _, _, replayCID, _, _ := readPV2Post(t, db, record.URI) + require.Equal(t, record.CID, replayCID) + require.Equal(t, before, postMentionRows(t, db, record.URI), + "Jetstream delivery after direct fetch must preserve mention row IDs and CIDs") +} + +func TestPostNotificationInsert_DirectFetchTwelveMentionsKeepsFirstTenOnJetstreamReplay(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newRealRepoFixture(t, db) + handles, recipients := postMentionRecipients(t, db, 12) + createdAt := activatedCommentNotificationTime(t, db, ctx) + postRecord := postMentionRecord(t, createdAt, handles, recipients) + postRecord["community"] = accCommunity + record := fixture.author.CreateRecord(t, PostV2Collection, postRecord) + consumer := newRealPostMentionConsumer(db, fixture, postgres.NewNotificationRepository(db), nil) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, record.URI), + "fixture: the post is not indexed before acceptance triggers direct fetch") + require.NoError(t, consumer.HandleEvent(ctx, acceptanceEvent(accCommunity, record.URI, record.CID, + testkit.TID(), time.Now().UnixMicro()))) + _, communityDID, indexedCID, _, deletedAt := readPV2Post(t, db, record.URI) + require.Equal(t, accCommunity, communityDID) + require.Equal(t, record.CID, indexedCID, "acceptance must index the fetched post before Jetstream arrives") + require.Nil(t, deletedAt) + requirePostMentionFacets(t, db, record.URI, recipients...) + + before := postMentionRows(t, db, record.URI) + require.Len(t, before, 10, "direct fetch must notify at most ten of the twelve mentioned users") + for index, row := range before { + require.Equal(t, recipients[index], row.recipient, "the first ten facets must win in order") + require.Equal(t, record.CID, row.cid) + } + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(fixture.author.DID, "create", record.RKey, + testkit.TID(), record.CID, time.Now().UnixMicro(), postRecord))) + require.Equal(t, before, postMentionRows(t, db, record.URI), + "Jetstream replay must preserve exactly the original ten mention rows and their IDs") +} + +func newRealPostMentionConsumer(db *sql.DB, fixture *realRepoFixture, repository notifications.Repository, fetcher PostRecordFetcher) *PostEventConsumer { + if fetcher == nil { + fetcher = NewDirectPostFetcher(pinnedResolver(fixture.author.DID, fixture.pds.URL()), PrivatePostFetcherOptions(true)...) + } + return NewPostEventConsumer( + postgres.NewPostRepository(db), postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), + newMockUserService(), db, + WithAdmissions(fixture.admissions), WithDeletedAccounts(postgres.NewDeletedAccountRepository(db)), + WithPostRecordFetcher(fetcher), WithPostNotifications(repository), + ) +} + +func TestPostNotificationInsert_RacingDirectFetchConflictKeepsFirstTen(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newRealRepoFixture(t, db) + handles, recipients := postMentionRecipients(t, db, 12) + createdAt := activatedCommentNotificationTime(t, db, ctx) + postRecord := postMentionRecord(t, createdAt, handles, recipients) + postRecord["community"] = accCommunity + record := fixture.author.CreateRecord(t, PostV2Collection, postRecord) + var consumer *PostEventConsumer + consumer = newRealPostMentionConsumer(db, fixture, postgres.NewNotificationRepository(db), &racingFetcher{ + inner: NewDirectPostFetcher(pinnedResolver(fixture.author.DID, fixture.pds.URL()), PrivatePostFetcherOptions(true)...), + before: func() { + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(fixture.author.DID, "create", record.RKey, + testkit.TID(), record.CID, time.Now().UnixMicro(), postRecord))) + _, _, indexedCID, _, _ := readPV2Post(t, db, record.URI) + require.Equal(t, record.CID, indexedCID, "fixture: Jetstream must win the insert race") + }, + }) + require.NoError(t, consumer.HandleEvent(ctx, acceptanceEvent(accCommunity, record.URI, record.CID, + testkit.TID(), time.Now().UnixMicro()))) + _, _, indexedCID, _, deletedAt := readPV2Post(t, db, record.URI) + require.Equal(t, record.CID, indexedCID) + require.Nil(t, deletedAt) + requirePostMentionFacets(t, db, record.URI, recipients...) + rows := postMentionRows(t, db, record.URI) + require.Len(t, rows, 10, "the insert winner must notify exactly the first ten, with no duplicate on fetch conflict") + for index, row := range rows { + require.Equal(t, recipients[index], row.recipient, "facet order determines the ten recipients") + require.Equal(t, record.CID, row.cid) + } +} + +func TestPostNotificationInsert_ConflictNeverCallsNotificationRepository(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + recordMap := postMentionRecord(t, createdAt, handles, recipients) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + const originalCID = "bafyreiinsertconflictoriginal" + firstConsumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + revs := increasingTIDs(t, 2) + require.NoError(t, firstConsumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + originalCID, time.Now().UnixMicro(), recordMap))) + _, _, indexedCID, _, _ := readPV2Post(t, db, uri) + require.Equal(t, originalCID, indexedCID) + requirePostMentionFacets(t, db, uri, recipients...) + // Seed an existing mention independently of the feature under development: + // the conflict must preserve a real row even while fan-out is still absent. + _, err := db.ExecContext(ctx, `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, root_post_uri, record_created_at) + VALUES ($1, 'mention', $2, $3, $4, $2, NOW()) ON CONFLICT DO NOTHING`, recipients[0], uri, originalCID, pv2Author) + require.NoError(t, err) + before := postMentionRows(t, db, uri) + require.Len(t, before, 1) + injectedError := errors.New("notification repository reached on insert conflict") + failing := &failingCommentNotificationRepository{delegate: postgres.NewNotificationRepository(db), failure: injectedError} + consumer := postMentionConsumer(db, fixture, failing) + parsed, err := parseAuthorPostRecord(recordMap) + require.NoError(t, err) + facetsJSON, err := json.Marshal(recordMap["facets"]) + require.NoError(t, err) + applied, err := consumer.insertAuthorPost(ctx, authorPostInsert{ + uri: uri, authorDID: pv2Author, record: parsed, + commit: &CommitEvent{Operation: "create", Collection: PostV2Collection, + RKey: key, Rev: revs[1], CID: "bafyreiinsertconflictnewer"}, + timeUS: time.Now().Add(time.Second).UnixMicro(), + facets: sql.NullString{String: string(facetsJSON), Valid: true}, + }) + require.NoError(t, err) + require.False(t, applied, "ON CONFLICT must report no post insertion") + _, _, indexedCID, _, _ = readPV2Post(t, db, uri) + require.Equal(t, originalCID, indexedCID) + require.Equal(t, before, postMentionRows(t, db, uri), "the conflicting insert must preserve existing mention rows") + require.Empty(t, failing.intents, "notification ApplyTx must not run for an insert conflict") +} + +func TestPostNotificationInsert_StaleRevNeverCallsNotificationRepository(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revs := increasingTIDs(t, 2) + won, err := tryAdvanceRecordRev(ctx, db, uri, revs[1]) + require.NoError(t, err) + require.True(t, won, "fixture: newer rev must be recorded without a post row") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, uri)) + failing := &failingCommentNotificationRepository{ + delegate: postgres.NewNotificationRepository(db), failure: errors.New("stale event reached ApplyTx"), + } + consumer := postMentionConsumer(db, fixture, failing) + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revs[0], + "bafyreistaleinsert", time.Now().UnixMicro(), postMentionRecord(t, createdAt, handles, recipients)))) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, uri), "stale create must not index") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri)) + require.Empty(t, failing.intents, "stale create must never call notification ApplyTx") +} + +func TestPostNotificationInsert_TwelveMentionsKeepFirstTen(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 12) + createdAt := activatedCommentNotificationTime(t, db, ctx) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + const cid = "bafyreipostmentioncap" + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, testkit.TID(), cid, + time.Now().UnixMicro(), postMentionRecord(t, createdAt, handles, recipients)))) + _, _, indexedCID, _, deletedAt := readPV2Post(t, db, uri) + require.Equal(t, cid, indexedCID) + require.Nil(t, deletedAt) + requirePostMentionFacets(t, db, uri, recipients...) + rows := postMentionRows(t, db, uri) + require.Len(t, rows, 10, "one post creates at most ten mentions") + for index, row := range rows { + require.Equal(t, recipients[index], row.recipient, "first ten facets win") + require.Equal(t, cid, row.cid) + } + require.Equal(t, 10, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri), + "post create must write only mention notifications") +} + +func TestPostNotificationInsert_ApplyFailureRollsBackJetstreamPostRevAndAdmission(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + injectedError := errors.New("injected post mention write failure") + failing := &failingCommentNotificationRepository{delegate: postgres.NewNotificationRepository(db), failure: injectedError} + consumer := postMentionConsumer(db, fixture, failing) + err := consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, testkit.TID(), + "bafyreipostrollback", time.Now().UnixMicro(), postMentionRecord(t, createdAt, handles, recipients))) + require.ErrorIs(t, err, injectedError, "notification failure must fail the entire post insert") + require.Len(t, failing.intents, 1, "fixture: mention fan-out must have reached ApplyTx") + require.Equal(t, recipients[0], failing.intents[0].RecipientDID) + require.Equal(t, notifications.ReasonMention, failing.intents[0].Reason) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, uri), "failed insert must roll back") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM jetstream_record_revs WHERE record_uri = $1`, uri), + "failed insert must not advance the rev") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM community_post_admissions WHERE post_uri = $1`, uri), + "failed insert must not seed an admission") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri)) +} + +func TestPostNotificationInsert_ApplyFailureRollsBackDirectFetch(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newRealRepoFixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + postRecord := postMentionRecord(t, createdAt, handles, recipients) + postRecord["community"] = accCommunity + record := fixture.author.CreateRecord(t, PostV2Collection, postRecord) + injectedError := errors.New("injected fetched post mention write failure") + failing := &failingCommentNotificationRepository{delegate: postgres.NewNotificationRepository(db), failure: injectedError} + consumer := newRealPostMentionConsumer(db, fixture, failing, nil) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, record.URI), + "fixture: post must be fetched rather than already indexed") + err := consumer.HandleEvent(ctx, acceptanceEvent(accCommunity, record.URI, record.CID, + testkit.TID(), time.Now().UnixMicro())) + require.ErrorIs(t, err, injectedError, "notification failure must fail direct-fetch insertion") + require.Len(t, failing.intents, 1, "fixture: fetched post's mention must reach ApplyTx") + require.Equal(t, recipients[0], failing.intents[0].RecipientDID) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, record.URI), + "fetched post insert must roll back with notification failure") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, record.URI)) +} + +func TestPostNotificationInsert_LegacyPostMentionIsDropped(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + const collection = "social.coves.community.post" + key := testkit.TID() + uri := "at://" + pv2Community + "/" + collection + "/" + key + record := postMentionRecord(t, createdAt, handles, recipients) + record["$type"] = collection + record["author"] = pv2Author + consumer := postMentionConsumer(db, fixture, postgres.NewNotificationRepository(db)) + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent(pv2Community, collection, "create", key, + testkit.TID(), "bafyreilegacymention", time.Now().UnixMicro(), record))) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, uri), + "fixture: retired legacy collection must remain unindexed") + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri), + "retired legacy collection must never fan out mentions") +} diff --git a/internal/atproto/jetstream/post_notification_mentions_test.go b/internal/atproto/jetstream/post_notification_mentions_test.go new file mode 100644 index 0000000..7afe6f5 --- /dev/null +++ b/internal/atproto/jetstream/post_notification_mentions_test.go @@ -0,0 +1,95 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/crypto/credentialcipher/credentialciphertest" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestPostConsumer_CreatePostV2NotifiesEligibleMentionedUsers(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + fixture := newPV2Fixture(t, db) + uniqueID := testkit.UniqueID(t) + firstRecipientDID := "did:plc:" + uniqueID + "first" + secondRecipientDID := "did:plc:" + uniqueID + "second" + firstHandle := uniqueID + "first.test" + secondHandle := uniqueID + "second.test" + insertBridgedUserOnPDS(t, db, firstRecipientDID, firstHandle, bridgedTestNativePDS) + insertBridgedUserOnPDS(t, db, secondRecipientDID, secondHandle, bridgedTestNativePDS) + createdAt := activatedCommentNotificationTime(t, db, ctx) + + consumer := NewPostEventConsumer( + postgres.NewPostRepository(db), + postgres.NewCommunityRepository(db, credentialciphertest.Fixed()), + fixture.users, db, + WithAdmissions(fixture.admissions), + WithDeletedAccounts(postgres.NewDeletedAccountRepository(db)), + WithPostNotifications(postgres.NewNotificationRepository(db)), + ) + const communityHandle = "pv2community.test" + const authorHandle = "pv2author.test" + // The community DID is also an indexed account, so only the community rule can exclude it. + insertBridgedUserOnPDS(t, db, pv2Community, communityHandle, bridgedTestNativePDS) + content := "A mentions @" + firstHandle + " @" + secondHandle + " @" + communityHandle + " @" + authorHandle + record := pv2Record(pv2Community, "Mentioned users", content) + record["createdAt"] = createdAt + record["facets"] = []interface{}{ + commentMentionFacet(t, content, firstHandle, firstRecipientDID), + commentMentionFacet(t, content, secondHandle, secondRecipientDID), + commentMentionFacet(t, content, communityHandle, pv2Community), + commentMentionFacet(t, content, authorHandle, pv2Author), + } + postKey := testkit.TID() + postURI := pv2URI(pv2Author, postKey) + const postCID = "bafyreipostmentioncreate" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event( + pv2Author, "create", postKey, testkit.TID(), postCID, time.Now().UnixMicro(), record, + ))) + + // Prove the event was admitted and the facets survived before inspecting fan-out. + authorDID, communityDID, indexedCID, _, deletedAt := readPV2Post(t, db, postURI) + require.Equal(t, pv2Author, authorDID) + require.Equal(t, pv2Community, communityDID) + require.Equal(t, postCID, indexedCID) + require.Nil(t, deletedAt) + var storedFacets sql.NullString + require.NoError(t, db.QueryRowContext(ctx, `SELECT content_facets FROM posts WHERE uri = $1`, postURI).Scan(&storedFacets)) + require.True(t, storedFacets.Valid, "fixture: valid mention facets must survive post indexing") + for _, mentionedDID := range []string{firstRecipientDID, secondRecipientDID, pv2Community, pv2Author} { + require.Contains(t, storedFacets.String, mentionedDID, "fixture: every mention must survive sanitization") + } + + for _, recipientDID := range []string{firstRecipientDID, secondRecipientDID} { + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, postURI, recipientDID), + "each eligible mentioned user must receive exactly one mention for the indexed post") + var actorDID, recordURI, recordCID, rootPostURI string + var subjectURI sql.NullString + require.NoError(t, db.QueryRowContext(ctx, `SELECT actor_did, record_uri, record_cid, subject_uri, root_post_uri + FROM notifications WHERE record_uri = $1 AND recipient_did = $2 AND reason = 'mention'`, + postURI, recipientDID).Scan(&actorDID, &recordURI, &recordCID, &subjectURI, &rootPostURI)) + require.Equal(t, pv2Author, actorDID) + require.Equal(t, postURI, recordURI) + require.Equal(t, postCID, recordCID) + require.False(t, subjectURI.Valid, "a post mention has no subject URI") + require.Equal(t, postURI, rootPostURI) + } + for _, excludedDID := range []string{pv2Community, pv2Author} { + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND recipient_did = $2`, postURI, excludedDID), + "a mentioned community or the post author must receive no notification") + } + require.Equal(t, 2, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, postURI), + "the post must produce only the two eligible mention notifications") +} diff --git a/internal/atproto/jetstream/post_notification_tombstone_test.go b/internal/atproto/jetstream/post_notification_tombstone_test.go new file mode 100644 index 0000000..6e1d7fa --- /dev/null +++ b/internal/atproto/jetstream/post_notification_tombstone_test.go @@ -0,0 +1,453 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +type postTombstoneFixture struct { + db *sql.DB + post pv2Fixture + consumer *PostEventConsumer + key, uri string + revisions []string + createdAt string + handle string + recipient string +} + +func newPostTombstoneFixture(t *testing.T) postTombstoneFixture { + t.Helper() + ctx := context.Background() + db := testkit.DB(t) + post := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + consumer := postMentionConsumer(db, post, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revisions := increasingTIDs(t, 3) + const cid = "bafyreiposttombstonecreate" + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revisions[1], + cid, time.Now().UnixMicro(), postMentionRecord(t, createdAt, handles, recipients)))) + require.Equal(t, cid, readPostV2MechanismRow(t, db, uri).CID) + require.Equal(t, revisions[1], readPostV2MechanismRev(t, db, uri)) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM notifications + WHERE record_uri = $1 AND reason = 'mention' AND recipient_did = $2`, uri, recipients[0]), + "fixture: create must leave a real mention row for the author delete") + return postTombstoneFixture{ + db: db, post: post, consumer: consumer, key: key, uri: uri, + revisions: revisions, createdAt: createdAt, handle: handles[0], recipient: recipients[0], + } +} + +func (fixture postTombstoneFixture) delete(revision string) error { + return fixture.consumer.HandleEvent(context.Background(), pv2Event(pv2Author, "delete", fixture.key, + revision, "", time.Now().UnixMicro(), nil)) +} + +type notificationRowSnapshot struct { + id int64 + recipient string + reason string + recordCID sql.NullString + subjectURI sql.NullString + rootPostURI string + sortAt time.Time +} + +func notificationRowsForRecordOrSubject(t *testing.T, db *sql.DB, uri string) []notificationRowSnapshot { + t.Helper() + rows, err := db.Query(`SELECT id, recipient_did, reason, record_cid, subject_uri, root_post_uri, sort_at + FROM notifications WHERE record_uri = $1 OR subject_uri = $1 ORDER BY id`, uri) + require.NoError(t, err) + defer rows.Close() + var snapshots []notificationRowSnapshot + for rows.Next() { + var snapshot notificationRowSnapshot + require.NoError(t, rows.Scan(&snapshot.id, &snapshot.recipient, &snapshot.reason, + &snapshot.recordCID, &snapshot.subjectURI, &snapshot.rootPostURI, &snapshot.sortAt)) + snapshots = append(snapshots, snapshot) + } + require.NoError(t, rows.Err()) + return snapshots +} + +func TestPostNotificationTombstone_KeepsDeletedPostsRecordRows(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + ctx := context.Background() + secondHandles, secondRecipients := postMentionRecipients(t, fixture.db, 1) + secondRecord := postMentionRecord(t, fixture.createdAt, + []string{fixture.handle, secondHandles[0]}, []string{fixture.recipient, secondRecipients[0]}) + revisions := increasingTIDs(t, 2) + require.NoError(t, fixture.consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", fixture.key, + revisions[0], "bafyreiposttombstonetwomentions", time.Now().UnixMicro(), secondRecord))) + // The edit only adds the second mention; the original row keeps its create CID. + require.Len(t, postMentionRows(t, fixture.db, fixture.uri), 2, + "fixture: the post must have two distinct mention recipients") + otherKey := testkit.TID() + otherURI := pv2URI(pv2Author, otherKey) + require.NoError(t, fixture.consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", otherKey, + testkit.TID(), "bafyreiposttombstoneother", time.Now().UnixMicro(), + postMentionRecord(t, fixture.createdAt, []string{fixture.handle}, []string{fixture.recipient})))) + otherRows := postMentionRows(t, fixture.db, otherURI) + require.Len(t, otherRows, 1, "fixture: other post must also have a mention row") + + // These rows navigate to or group votes for the deleted post, but belong + // to another record (or have no record_uri). Neither may be swept by URI. + commentURI := "at://" + fixture.recipient + "/" + CommentCollection + "/" + testkit.TID() + _, err := fixture.db.ExecContext(ctx, `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at) + VALUES ($1, 'commentReply', $2, $3, $4, $5, $5, NOW())`, + pv2Author, commentURI, "bafyreiposttombstonecomment", fixture.recipient, fixture.uri) + require.NoError(t, err) + _, err = fixture.db.ExecContext(ctx, `INSERT INTO notifications + (recipient_did, reason, subject_uri, root_post_uri) + VALUES ($1, 'upvote', $2, $2)`, pv2Author, fixture.uri) + require.NoError(t, err) + var replyID, groupID int64 + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT id FROM notifications WHERE record_uri = $1 AND reason = 'commentReply'`, commentURI).Scan(&replyID)) + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT id FROM notifications WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + pv2Author, fixture.uri).Scan(&groupID)) + before := notificationRowsForRecordOrSubject(t, fixture.db, fixture.uri) + require.Len(t, before, 4, "fixture: two mentions, another record's reply and the subject's upvote group") + require.NoError(t, fixture.delete(revisions[1])) + _, _, _, _, deletedAt := readPV2Post(t, fixture.db, fixture.uri) + require.NotNil(t, deletedAt, "winning author delete must soft-delete its post") + require.Equal(t, revisions[1], readPostV2MechanismRev(t, fixture.db, fixture.uri)) + require.Equal(t, before, notificationRowsForRecordOrSubject(t, fixture.db, fixture.uri), + "both mentions must keep their IDs, recipients, reasons, CIDs, roots and sort times") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE id = $1 AND record_uri = $2 AND root_post_uri = $3 AND subject_uri = $3 AND reason = 'commentReply'`, + replyID, commentURI, fixture.uri), "another record's reply rooted at the post must survive") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE id = $1 AND reason = 'upvote' AND record_uri IS NULL AND subject_uri = $2`, groupID, fixture.uri), + "the deleted post's upvote group belongs to its subject, not its record URI") + require.Equal(t, otherRows, postMentionRows(t, fixture.db, otherURI), + "a mention from a different post must retain its ID and CID") +} + +func TestPostNotificationTombstone_StaleDeleteKeepsPostAndNotifications(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + before := postMentionRows(t, fixture.db, fixture.uri) + require.Less(t, fixture.revisions[0], fixture.revisions[1], "fixture: delete loses the rev gate") + require.NoError(t, fixture.delete(fixture.revisions[0])) + _, _, cid, _, deletedAt := readPV2Post(t, fixture.db, fixture.uri) + require.Nil(t, deletedAt, "stale tombstone must leave the post active") + require.Equal(t, "bafyreiposttombstonecreate", cid) + require.Equal(t, before, postMentionRows(t, fixture.db, fixture.uri), "stale tombstone must preserve the mention row") + require.Equal(t, fixture.revisions[1], readPostV2MechanismRev(t, fixture.db, fixture.uri)) +} + +func TestPostNotificationTombstone_AlreadySoftDeletedKeepsLeftoverMention(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + _, err := fixture.db.Exec(`UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, fixture.uri) + require.NoError(t, err) + before := notificationRowsForRecordOrSubject(t, fixture.db, fixture.uri) + require.Len(t, before, 1, + "fixture: a previously soft-deleted post retains a leftover mention") + require.NoError(t, fixture.delete(fixture.revisions[2])) + _, _, _, _, deletedAt := readPV2Post(t, fixture.db, fixture.uri) + require.NotNil(t, deletedAt) + require.Equal(t, before, notificationRowsForRecordOrSubject(t, fixture.db, fixture.uri), + "a zero-row tombstone must keep the leftover mention unchanged") + require.Equal(t, fixture.revisions[2], readPostV2MechanismRev(t, fixture.db, fixture.uri)) +} + +func TestPostNotificationTombstone_AuthorDeleteMarkerRecordsOnlyPubliclyVisiblePosts(t *testing.T) { + for _, testCase := range []struct { + name string + status string + mismatchedCID bool + noAdmission bool + softDeleted bool + stale bool + seededMarker bool + adminRemoved bool + wantMarker bool + withdrawLater bool + }{ + {name: "accepted then withdrawn", status: "accepted", wantMarker: true, withdrawLater: true}, + {name: "accepted with active server-admin removal", status: "accepted", adminRemoved: true, wantMarker: true}, + {name: "accepted already soft deleted", status: "accepted", softDeleted: true, wantMarker: true}, + {name: "pending", status: "pending"}, + {name: "rejected", status: "rejected"}, + {name: "pending reacceptance", status: "pending_reacceptance"}, + {name: "accepted with stale CID", status: "accepted", mismatchedCID: true}, + {name: "no admission", noAdmission: true}, + {name: "stale tombstone", status: "accepted", stale: true}, + {name: "existing author delete marker", status: "accepted", softDeleted: true, seededMarker: true, wantMarker: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + ctx := context.Background() + const postCID = "bafyreiposttombstonecreate" + if testCase.noAdmission { + _, err := fixture.db.ExecContext(ctx, `DELETE FROM community_post_admissions + WHERE community_did = $1 AND post_uri = $2`, pv2Community, fixture.uri) + require.NoError(t, err) + } else { + acceptedCID := sql.NullString{} + acceptanceURI := sql.NullString{} + acceptanceRKey := sql.NullString{} + decisionCode := sql.NullString{} + if testCase.status == "accepted" || testCase.status == "pending_reacceptance" { + acceptedCID = sql.NullString{String: postCID, Valid: true} + if testCase.mismatchedCID { + acceptedCID.String = "bafyreiposttombstonedifferent" + } + acceptanceRKey = sql.NullString{String: testkit.TID(), Valid: true} + acceptanceURI = sql.NullString{String: "at://" + pv2Community + "/social.coves.community.acceptance/" + acceptanceRKey.String, Valid: true} + } + if testCase.status == "rejected" { + decisionCode = sql.NullString{String: "policy", Valid: true} + } + result, err := fixture.db.ExecContext(ctx, `UPDATE community_post_admissions + SET status = $3, accepted_cid = $4, acceptance_uri = $5, acceptance_rkey = $6, + decision_code = $7, evaluated_cid = $8 + WHERE community_did = $1 AND post_uri = $2`, pv2Community, fixture.uri, + testCase.status, acceptedCID, acceptanceURI, acceptanceRKey, decisionCode, postCID) + require.NoError(t, err) + updated, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, updated, "fixture: the post's own-community admission must exist") + } + if testCase.softDeleted { + _, err := fixture.db.ExecContext(ctx, `UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, fixture.uri) + require.NoError(t, err) + } + if testCase.adminRemoved { + seedPostInstanceRemoval(t, fixture.db, fixture.uri) + } + recordedAt := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + if testCase.seededMarker { + _, err := fixture.db.ExecContext(ctx, `INSERT INTO notification_public_post_withdrawals + (post_uri, kind, recorded_at) VALUES ($1, 'authorDelete', $2)`, fixture.uri, recordedAt) + require.NoError(t, err) + } + + revision := fixture.revisions[2] + if testCase.stale { + revision = fixture.revisions[0] + } + require.NoError(t, fixture.delete(revision)) + if testCase.stale { + require.Equal(t, fixture.revisions[1], readPostV2MechanismRev(t, fixture.db, fixture.uri)) + _, _, _, _, deletedAt := readPV2Post(t, fixture.db, fixture.uri) + require.Nil(t, deletedAt, "stale tombstone must not delete the post") + } else { + require.Equal(t, revision, readPostV2MechanismRev(t, fixture.db, fixture.uri)) + _, _, _, _, deletedAt := readPV2Post(t, fixture.db, fixture.uri) + require.NotNil(t, deletedAt, "winning tombstone must soft-delete the post") + } + markers := postAuthorDeleteMarkers(t, fixture.db, fixture.uri) + if testCase.wantMarker { + require.Len(t, markers, 1, "publicly visible post must retain one authorDelete marker") + require.Equal(t, fixture.uri, markers[0].postURI) + require.Equal(t, "authorDelete", markers[0].kind) + require.False(t, markers[0].communityRev.Valid) + if testCase.seededMarker { + require.Equal(t, recordedAt, markers[0].recordedAt, "conflict must not restamp the existing marker") + } + } else { + require.Empty(t, markers, "post hidden from anonymous viewers must have no authorDelete marker") + } + if testCase.withdrawLater { + result, err := postgres.NewAdmissionRepository(fixture.db).ApplyAcceptanceDelete(ctx, posts.CommunityDeleteCommand{ + CommunityDID: pv2Community, PostURI: fixture.uri, + Watermark: posts.CommunityWatermark{Rev: testkit.TID(), OpRank: posts.CommunityOpDelete}, + }) + require.NoError(t, err) + require.Equal(t, posts.AdmissionStatusPending, result.Admission.Status) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM community_post_admissions + WHERE community_did = $1 AND post_uri = $2 AND status = 'pending' AND accepted_cid IS NULL`, pv2Community, fixture.uri)) + require.Equal(t, markers, postAuthorDeleteMarkers(t, fixture.db, fixture.uri), + "later acceptance withdrawal must not erase the committed author-delete marker") + } + }) + } +} + +// seedPostInstanceRemoval records an active instance-scope admin removal of +// postURI: the action row, then the decision it made active. +func seedPostInstanceRemoval(t *testing.T, db *sql.DB, postURI string) { + t.Helper() + actionID := "post-tombstone-removal-" + testkit.TID() + _, err := db.Exec(`INSERT INTO moderation_actions + (id, actor_did, authority_did, scope_kind, subject_uri, subject_collection, action, origin, created_at) + VALUES ($1, 'did:plc:posttombstonemoderator', 'did:plc:posttombstoneinstance', 'instance', $2, $3, 'remove', 'local', NOW())`, + actionID, postURI, posts.PostV2Collection) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO moderation_decisions + (authority_did, scope_kind, subject_uri, kind, active_action_id, active) + VALUES ('did:plc:posttombstoneinstance', 'instance', $1, 'removal', $2, true)`, postURI, actionID) + require.NoError(t, err) +} + +type postAuthorDeleteMarker struct { + postURI string + kind string + communityRev sql.NullString + recordedAt time.Time +} + +func postAuthorDeleteMarkers(t *testing.T, db *sql.DB, postURI string) []postAuthorDeleteMarker { + t.Helper() + rows, err := db.Query(`SELECT post_uri, kind, community_rev, recorded_at + FROM notification_public_post_withdrawals WHERE post_uri = $1 AND kind = 'authorDelete'`, postURI) + require.NoError(t, err) + defer rows.Close() + var markers []postAuthorDeleteMarker + for rows.Next() { + var marker postAuthorDeleteMarker + require.NoError(t, rows.Scan(&marker.postURI, &marker.kind, &marker.communityRev, &marker.recordedAt)) + marker.recordedAt = marker.recordedAt.UTC() + markers = append(markers, marker) + } + require.NoError(t, rows.Err()) + return markers +} + +func TestPostNotificationTombstone_NeverIndexedPostAdvancesRevision(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + post := newPV2Fixture(t, db) + consumer := postMentionConsumer(db, post, postgres.NewNotificationRepository(db)) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revision := testkit.TID() + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, uri), "fixture: post has never been indexed") + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "delete", key, revision, "", time.Now().UnixMicro(), nil))) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, uri)) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, uri)) + require.Equal(t, revision, readPostV2MechanismRev(t, db, uri), "delete-first tombstone must fence out stale creates") +} + +// failingPostTombstoneNotificationRepository runs the real repository call at +// failurePoint, then returns failure, so the injected error arrives after the +// real statement has already touched the transaction. +type failingPostTombstoneNotificationRepository struct { + notifications.Repository + failurePoint string + failure error +} + +const ( + postTombstoneFailAtErasureGate = "erasure" + postTombstoneFailAtMarker = "marker" +) + +func (repository *failingPostTombstoneNotificationRepository) ErasureGateTx(ctx context.Context, tx *sql.Tx, did string) (bool, error) { + erased, err := repository.Repository.ErasureGateTx(ctx, tx, did) + if err != nil || repository.failurePoint != postTombstoneFailAtErasureGate { + return erased, err + } + return false, repository.failure +} + +func (repository *failingPostTombstoneNotificationRepository) RecordPostAuthorDeleteWithdrawalTx(ctx context.Context, tx *sql.Tx, uri string) error { + if err := repository.Repository.RecordPostAuthorDeleteWithdrawalTx(ctx, tx, uri); err != nil { + return err + } + if repository.failurePoint != postTombstoneFailAtMarker { + return nil + } + return repository.failure +} + +type recordingPostTombstoneIsolationRepository struct { + notifications.Repository + isolation string +} + +func (repository *recordingPostTombstoneIsolationRepository) ErasureGateTx(ctx context.Context, tx *sql.Tx, did string) (bool, error) { + if err := tx.QueryRowContext(ctx, `SELECT current_setting('transaction_isolation')`).Scan(&repository.isolation); err != nil { + return false, err + } + return repository.Repository.ErasureGateTx(ctx, tx, did) +} + +func TestPostNotificationTombstone_FailureRollsBackPostRowsMarkerAndRev(t *testing.T) { + t.Parallel() + for _, failurePoint := range []string{postTombstoneFailAtErasureGate, postTombstoneFailAtMarker} { + t.Run(failurePoint, func(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + ctx := context.Background() + acceptanceKey := testkit.TID() + result, err := fixture.db.ExecContext(ctx, `UPDATE community_post_admissions + SET status = 'accepted', accepted_cid = $2, evaluated_cid = $2, + acceptance_uri = $3, acceptance_rkey = $4 + WHERE post_uri = $1 AND community_did = $5`, fixture.uri, "bafyreiposttombstonecreate", + "at://"+pv2Community+"/social.coves.community.acceptance/"+acceptanceKey, acceptanceKey, pv2Community) + require.NoError(t, err) + updated, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, updated, "fixture: public acceptance must match the post CID") + before := notificationRowsForRecordOrSubject(t, fixture.db, fixture.uri) + injectedError := errors.New("injected post tombstone " + failurePoint + " failure") + fixture.consumer = postMentionConsumer(fixture.db, fixture.post, &failingPostTombstoneNotificationRepository{ + Repository: postgres.NewNotificationRepository(fixture.db), + failurePoint: failurePoint, + failure: injectedError, + }) + err = fixture.delete(fixture.revisions[2]) + require.ErrorIs(t, err, injectedError, "a failure at the %s step must surface and roll back the entire tombstone", failurePoint) + _, _, cid, _, deletedAt := readPV2Post(t, fixture.db, fixture.uri) + require.Nil(t, deletedAt, "a failure at the %s step must leave the post active", failurePoint) + require.Equal(t, "bafyreiposttombstonecreate", cid) + require.Equal(t, before, notificationRowsForRecordOrSubject(t, fixture.db, fixture.uri), + "a failure at the %s step must leave the post's notification rows unchanged", failurePoint) + require.Equal(t, fixture.revisions[1], readPostV2MechanismRev(t, fixture.db, fixture.uri), + "a failure at the %s step must roll back the revision gate", failurePoint) + require.Empty(t, postAuthorDeleteMarkers(t, fixture.db, fixture.uri), + "a failure at the %s step must roll back the author-delete marker", failurePoint) + }) + } +} + +func TestPostNotificationTombstone_RequestsReadCommittedExplicitly(t *testing.T) { + t.Parallel() + fixture := newPostTombstoneFixture(t) + ctx := context.Background() + var database string + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT current_database()`).Scan(&database)) + _, err := fixture.db.ExecContext(ctx, + "ALTER DATABASE "+pq.QuoteIdentifier(database)+" SET default_transaction_isolation = 'repeatable read'") + require.NoError(t, err) + fixture.db.SetMaxIdleConns(0) // New sessions must see the changed database default. + control, err := fixture.db.BeginTx(ctx, nil) + require.NoError(t, err) + var defaultIsolation string + require.NoError(t, control.QueryRowContext(ctx, `SELECT current_setting('transaction_isolation')`).Scan(&defaultIsolation)) + require.NoError(t, control.Rollback()) + require.Equal(t, "repeatable read", defaultIsolation, + "control: a transaction without explicit isolation inherits the database default") + observed := &recordingPostTombstoneIsolationRepository{Repository: postgres.NewNotificationRepository(fixture.db)} + fixture.consumer = postMentionConsumer(fixture.db, fixture.post, observed) + require.NoError(t, fixture.delete(fixture.revisions[2]), "tombstone must use READ COMMITTED despite the database default") + require.Equal(t, "read committed", observed.isolation, "the delete's real erasure gate must run in READ COMMITTED") + _, _, _, _, deletedAt := readPV2Post(t, fixture.db, fixture.uri) + require.NotNil(t, deletedAt) + require.Equal(t, fixture.revisions[2], readPostV2MechanismRev(t, fixture.db, fixture.uri)) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE record_uri = $1`, fixture.uri), + "the delete must keep its notification rows under READ COMMITTED") +} diff --git a/internal/atproto/jetstream/post_notification_withdrawn_gate_test.go b/internal/atproto/jetstream/post_notification_withdrawn_gate_test.go new file mode 100644 index 0000000..3750ab6 --- /dev/null +++ b/internal/atproto/jetstream/post_notification_withdrawn_gate_test.go @@ -0,0 +1,90 @@ +//go:build integration + +package jetstream + +import ( + "context" + "testing" + "time" + + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestPostConsumer_RemovedBeforeJetstreamInsertSuppressesMention(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + f := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + result, err := f.admissions.ApplyRemoval(ctx, posts.ApplyRemovalCommand{ + CommunityDID: pv2Community, PostURI: uri, DecisionCode: string(posts.DecisionRuleViolation), + Watermark: posts.CommunityWatermark{Rev: testkit.TID()}, + }) + require.NoError(t, err) + require.Equal(t, posts.AdmissionApplied, result.Outcome) + require.NoError(t, postMentionConsumer(db, f, postgres.NewNotificationRepository(db)).HandleEvent(ctx, + pv2Event(pv2Author, "create", key, testkit.TID(), "bafyreiwithdrawninsert", time.Now().UnixMicro(), + postMentionRecord(t, createdAt, handles, recipients)))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, uri)) + requirePostMentionFacets(t, db, uri, recipients[0]) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, uri, recipients[0])) +} + +func TestPostConsumer_RemovedBeforeAcceptanceDirectFetchSuppressesMention(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + f := newRealRepoFixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + record := postMentionRecord(t, activatedCommentNotificationTime(t, db, ctx), handles, recipients) + record["community"] = accCommunity + created := f.author.CreateRecord(t, PostV2Collection, record) + revisions := increasingTIDs(t, 2) + result, err := f.admissions.ApplyRemoval(ctx, posts.ApplyRemovalCommand{ + CommunityDID: accCommunity, PostURI: created.URI, DecisionCode: string(posts.DecisionRuleViolation), + Watermark: posts.CommunityWatermark{Rev: revisions[1]}, + }) + require.NoError(t, err) + require.Equal(t, posts.AdmissionApplied, result.Outcome) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, created.URI)) + consumer := newRealPostMentionConsumer(db, f, postgres.NewNotificationRepository(db), nil) + require.NoError(t, consumer.HandleEvent(ctx, acceptanceEvent(accCommunity, created.URI, created.CID, + revisions[0], time.Now().UnixMicro()))) + require.Equal(t, 1, countRows(t, db, `SELECT count(*) FROM posts WHERE uri = $1`, created.URI)) + requirePostMentionFacets(t, db, created.URI, recipients[0]) + admission, err := f.admissions.Get(ctx, accCommunity, created.URI) + require.NoError(t, err) + require.Equal(t, posts.AdmissionStatusRemoved, admission.Status) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, created.URI, recipients[0])) +} + +func TestPostConsumer_RemovedPostEditDoesNotMentionNewRecipient(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + f := newPV2Fixture(t, db) + handles, recipients := postMentionRecipients(t, db, 1) + createdAt := activatedCommentNotificationTime(t, db, ctx) + key := testkit.TID() + uri := pv2URI(pv2Author, key) + revisions := increasingTIDs(t, 2) + consumer := postMentionConsumer(db, f, postgres.NewNotificationRepository(db)) + record := pv2Record(pv2Community, "Before removal", "No mentions") + record["createdAt"] = createdAt + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "create", key, revisions[0], + "bafyreiwithdrawnoriginal", time.Now().UnixMicro(), record))) + removeNotificationReferencePost(t, db, uri) + require.NoError(t, consumer.HandleEvent(ctx, pv2Event(pv2Author, "update", key, revisions[1], + "bafyreiwithdrawnedit", time.Now().Add(time.Second).UnixMicro(), postMentionRecord(t, createdAt, handles, recipients)))) + _, _, indexedCID, _, _ := readPV2Post(t, db, uri) + require.Equal(t, "bafyreiwithdrawnedit", indexedCID) + requirePostMentionFacets(t, db, uri, recipients[0]) + require.Zero(t, countRows(t, db, `SELECT count(*) FROM notifications WHERE record_uri = $1 AND recipient_did = $2`, uri, recipients[0])) +} diff --git a/internal/atproto/jetstream/vote_bridged_notification_race_test.go b/internal/atproto/jetstream/vote_bridged_notification_race_test.go new file mode 100644 index 0000000..c6f392f --- /dev/null +++ b/internal/atproto/jetstream/vote_bridged_notification_race_test.go @@ -0,0 +1,168 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "strings" + "sync" + "testing" + "time" + + "Coves/internal/core/bridgedvotes" + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +type bridgedVoteRaceRepository struct { + notifications.Repository + reached chan<- int + release <-chan struct{} +} + +func (r bridgedVoteRaceRepository) ApplyUpvoteGroupTx(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + var pid int + if err := tx.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&pid); err != nil { + return err + } + r.reached <- pid + select { + case <-r.release: + case <-ctx.Done(): + return ctx.Err() + } + return r.Repository.ApplyUpvoteGroupTx(ctx, tx, intent) +} + +type bridgedVoteRaceApplyResult struct { + applied bool + err error +} + +func bridgedVoteRacePID(t *testing.T, ctx context.Context, reached <-chan int, operation string) int { + t.Helper() + wait, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + select { + case pid := <-reached: + return pid + case <-wait.Done(): + t.Fatalf("%s never reached ApplyUpvoteGroupTx: %v", operation, wait.Err()) + return 0 + } +} + +func bridgedVoteRaceBlocked(t *testing.T, db *sql.DB, ctx context.Context, winnerPID int, queryFragment string) { + t.Helper() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + var pid int + err := db.QueryRowContext(ctx, `SELECT pid FROM pg_stat_activity + WHERE datname = current_database() AND pid <> pg_backend_pid() + AND $1 = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock' + AND query ILIKE $2 LIMIT 1`, winnerPID, "%"+queryFragment+"%").Scan(&pid) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("%s waiting behind winning subject row lock", queryFragment)) +} + +func bridgedVoteRaceError(t *testing.T, ctx context.Context, result <-chan error) { + t.Helper() + select { + case err := <-result: + var databaseError *pq.Error + require.False(t, errors.As(err, &databaseError) && databaseError.Code == "40P01", "vote delete deadlocked: %v", err) + require.NoError(t, err) + case <-ctx.Done(): + t.Fatalf("vote delete did not finish: %v", ctx.Err()) + } +} + +func bridgedVoteRaceApplied(t *testing.T, ctx context.Context, result <-chan bridgedVoteRaceApplyResult) { + t.Helper() + select { + case outcome := <-result: + var databaseError *pq.Error + require.False(t, errors.As(outcome.err, &databaseError) && databaseError.Code == "40P01", "bridged aggregate deadlocked: %v", outcome.err) + require.NoError(t, outcome.err) + require.True(t, outcome.applied) + case <-ctx.Done(): + t.Fatalf("bridged aggregate did not finish: %v", ctx.Err()) + } +} + +func TestVoteConsumer_BridgedIncreaseAndLastNativeVoteDeleteSerialize(t *testing.T) { + for _, order := range []string{"native delete first", "bridged increase first"} { + t.Run(order, func(t *testing.T) { + fixture := newUpvoteGroupFixture(t) + // The fixture's author can live on the bridge host; the recipient must be native. + insertBridgedUserOnPDS(t, fixture.db, fixture.author, testkit.UniqueID(t)+"author.test", bridgedTestNativePDS) + notificationRepo := postgres.NewNotificationRepository(fixture.db, postgres.WithBridgedUpvoteTotals()) + consumer := fixture.consumer(WithVoteNotifications(notificationRepo)) + voter := fixture.voter(t) + voteURI := deliverGroupVote(t, consumer, voter, fixture.post, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + key := strings.TrimPrefix(voteURI, "at://"+voter+"/social.coves.feed.vote/") + deleteEvent := revCommitEvent(voter, "social.coves.feed.vote", "delete", key, + testkit.TID(), "", time.Now().UnixMicro()+1_000_000, nil) + var now time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT now()`).Scan(&now)) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + t.Cleanup(cancel) + release := make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + reached := make(chan int, 1) + blocking := bridgedVoteRaceRepository{Repository: notificationRepo, reached: reached, release: release} + voteResults := make(chan error, 1) + aggregateResults := make(chan bridgedVoteRaceApplyResult, 1) + startDelete := func(repo notifications.Repository) { + voteConsumer := fixture.consumer(WithVoteNotifications(repo)) + go func() { voteResults <- voteConsumer.HandleEvent(ctx, deleteEvent) }() + } + startAggregate := func(repo notifications.Repository) { + store := postgres.NewBridgedVotesRepository(fixture.db, postgres.WithBridgedVoteNotifications(repo, nil)) + go func() { + applied, err := store.ApplyAggregate(ctx, bridgedvotes.Aggregate{URI: fixture.post, Upvotes: 2, AsOf: now.Add(time.Minute)}) + aggregateResults <- bridgedVoteRaceApplyResult{applied, err} + }() + } + if order == "native delete first" { + startDelete(blocking) + pid := bridgedVoteRacePID(t, ctx, reached, "native vote delete") + startAggregate(notificationRepo) + bridgedVoteRaceBlocked(t, fixture.db, ctx, pid, "posts") + } else { + startAggregate(blocking) + pid := bridgedVoteRacePID(t, ctx, reached, "bridged aggregate") + startDelete(notificationRepo) + bridgedVoteRaceBlocked(t, fixture.db, ctx, pid, "UPDATE posts") + } + select { + case err := <-voteResults: + t.Fatalf("vote delete completed before the winner was released: %v", err) + default: + } + select { + case result := <-aggregateResults: + t.Fatalf("bridged aggregate completed before the winner was released: %+v", result) + default: + } + unblock() + bridgedVoteRaceError(t, ctx, voteResults) + bridgedVoteRaceApplied(t, ctx, aggregateResults) + exists, active := voteRowState(t, fixture.db, voteURI) + require.True(t, exists && !active, "the last native upvote must be retracted") + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "bridged upvotes keep the group after the last native upvote is deleted") + }) + } +} diff --git a/internal/atproto/jetstream/vote_consumer.go b/internal/atproto/jetstream/vote_consumer.go index 3695431..9ca22de 100644 --- a/internal/atproto/jetstream/vote_consumer.go +++ b/internal/atproto/jetstream/vote_consumer.go @@ -1,10 +1,6 @@ package jetstream import ( - "Coves/internal/atproto/utils" - "Coves/internal/core/posts" - "Coves/internal/core/users" - "Coves/internal/core/votes" "context" "database/sql" "errors" @@ -12,6 +8,12 @@ import ( "log" "strings" "time" + + "Coves/internal/atproto/utils" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/core/users" + "Coves/internal/core/votes" ) // A vote names its subject by AT-URI and nothing else, so the collection segment @@ -46,6 +48,11 @@ type VoteEventConsumer struct { // deletedAccounts gates votes whose SUBJECT lives in an erased account's // repo. nil means no gate. deletedAccounts DeletedAccountLookup + // notifications writes upvote groups inside the index transaction; nil disables them. + notifications notifications.Repository + // bridgeTrust filters recipients hosted on trusted bridge PDS hosts; nil + // trusts no host. + bridgeTrust *BridgeTrust } // VoteEventConsumerOption configures optional VoteEventConsumer behaviour. @@ -58,6 +65,25 @@ func WithVoteDeletedAccounts(lookup DeletedAccountLookup) VoteEventConsumerOptio return func(c *VoteEventConsumer) { c.deletedAccounts = lookup } } +// WithVoteNotifications installs the repository that writes upvote groups for +// eligible newly indexed votes. It also installs the voter's shared erasure +// lock, taken before any content row is read. nil disables both. +func WithVoteNotifications(repository notifications.Repository) VoteEventConsumerOption { + return func(c *VoteEventConsumer) { c.notifications = repository } +} + +// WithVoteBridgeTrust installs the trusted bridge PDS hosts used to filter +// upvote group recipients. +func WithVoteBridgeTrust(bt *BridgeTrust) VoteEventConsumerOption { + return func(c *VoteEventConsumer) { c.bridgeTrust = bt } +} + +// NotificationsWired reports whether the consumer writes upvote groups. +func (c *VoteEventConsumer) NotificationsWired() bool { return c.notifications != nil } + +// BridgeTrustWired reports whether the consumer holds a bridge trust gate. +func (c *VoteEventConsumer) BridgeTrustWired() bool { return c.bridgeTrust != nil } + // NewVoteEventConsumer creates a new Jetstream consumer for vote events func NewVoteEventConsumer( voteRepo votes.Repository, @@ -135,12 +161,9 @@ func (c *VoteEventConsumer) createVote(ctx context.Context, repoDID string, comm // Format: at://voter_did/social.coves.feed.vote/rkey uri := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", repoDID, commit.RKey) - // Parse timestamp from record - createdAt, err := time.Parse(time.RFC3339, voteRecord.CreatedAt) - if err != nil { - log.Printf("Warning: Failed to parse createdAt timestamp, using current time: %v", err) - createdAt = time.Now() - } + // A future createdAt is clamped to now, as the post and comment consumers + // do, so neither the stored vote nor the notification gates see it. + createdAt := parseRecordCreatedAt(voteRecord.CreatedAt, uri) // Build vote entity vote := &votes.Vote{ @@ -177,11 +200,14 @@ func (c *VoteEventConsumer) createVote(ctx context.Context, repoDID string, comm // or blocks until our tombstone commits (its equal-or-older rev then loses // the gate). The gate row is advanced — and committed — even when the vote // was never indexed, so the create's late copy is rejected too. +// The voter's shared erasure lock follows the rev claim but precedes the vote +// read: waiting for it while holding a vote, post or comment row could +// deadlock with account erasure. Erased voters' deletes still proceed. func (c *VoteEventConsumer) deleteVote(ctx context.Context, repoDID string, commit *CommitEvent) error { // Build AT-URI for the vote being deleted uri := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", repoDID, commit.RKey) - tx, err := c.db.BeginTx(ctx, nil) + tx, err := c.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } @@ -202,6 +228,11 @@ func (c *VoteEventConsumer) deleteVote(ctx context.Context, repoDID string, comm logSkippedStaleRev(ConsumerVotes, "delete", uri, commit.Rev) return nil } + if c.notifications != nil { + if _, err := c.notifications.ErasureGateTx(ctx, tx, repoDID); err != nil { + return fmt.Errorf("check vote voter erasure before deleting: %w", err) + } + } // 1. Load the vote INSIDE the gate transaction: direction and subject // drive the count decrement below, and reading under the gate claim means @@ -329,6 +360,17 @@ func (c *VoteEventConsumer) deleteVote(ctx context.Context, repoDID string, comm if rowsAffected == 0 { log.Printf("Warning: Vote subject no longer exists: %s (vote deleted anyway)", subjectURI) } + // Delete-if-empty follows the decrement while we hold the subject row lock. + if c.notifications != nil { + intent, err := notifications.FanoutVoteRemoval(ctx, c.notifications.LookupsTx(tx), + notifications.VoteRecord{SubjectURI: subjectURI}) + if err != nil { + return fmt.Errorf("compute vote deletion notifications: %w", err) + } + if err := c.notifications.ApplyUpvoteGroupTx(ctx, tx, intent); err != nil { + return fmt.Errorf("write vote deletion notifications: %w", err) + } + } // Commit transaction if err := tx.Commit(); err != nil { @@ -342,7 +384,7 @@ func (c *VoteEventConsumer) deleteVote(ctx context.Context, repoDID string, comm // indexVoteAndUpdateCounts atomically indexes a vote and updates post vote counts // Returns (true, nil) if vote was newly inserted, (false, nil) if already existed (idempotent) func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote, rev string) (bool, error) { - tx, err := c.db.BeginTx(ctx, nil) + tx, err := c.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return false, fmt.Errorf("failed to begin transaction: %w", err) } @@ -366,6 +408,18 @@ func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote * logSkippedStaleRev(ConsumerVotes, "create", vote.URI, rev) return false, nil } + var voterErased bool + if c.notifications != nil { + // Lock before touching votes, posts or comments: waiting for the shared + // erasure lock while holding a content row could deadlock with Delete. An + // erased voter's vote is still indexed; fan-out determines eligibility + // from this gate's answer, which the lock keeps true for the transaction, + // and performs upvote-group maintenance under this lock. + voterErased, err = c.notifications.ErasureGateTx(ctx, tx, vote.VoterDID) + if err != nil { + return false, fmt.Errorf("check vote voter erasure before indexing: %w", err) + } + } // 1. ORDERING GATE: a vote is only indexed onto a subject that is present // and live. The subject's deleted_at is read WITHOUT filtering on it because @@ -553,6 +607,20 @@ func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote * // adjustments) through the create path. Comments handle their analogous // case in place (see indexCommentAndUpdateCounts). // + // A stale vote removed above changed the vote set, so run delete-if-empty + // maintenance before this early commit. It must stay after the stale + // decrement: that holds the subject row, which a concurrent vote's count + // update also takes before its group bump. + if existingDirection.Valid && c.notifications != nil { + intent, err := notifications.FanoutVoteRemoval(ctx, c.notifications.LookupsTx(tx), + notifications.VoteRecord{SubjectURI: vote.SubjectURI}) + if err != nil { + return false, fmt.Errorf("compute stale vote notifications: %w", err) + } + if err := c.notifications.ApplyUpvoteGroupTx(ctx, tx, intent); err != nil { + return false, fmt.Errorf("write stale vote notifications: %w", err) + } + } // Silently handle the common idempotent case - no log needed for replays. if commitErr := tx.Commit(); commitErr != nil { return false, fmt.Errorf("failed to commit transaction: %w", commitErr) @@ -646,6 +714,24 @@ func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote * return false, fmt.Errorf("vote subject %s disappeared while counting the vote: retry once the gate can classify it", vote.SubjectURI) } + // Fan-out stays after the count update (or the stale decrement before it): + // delete-if-empty is correct only while this transaction holds the subject row. + if c.notifications != nil { + subjectRootURI, err := voteSubjectRootAfterCountUpdate(ctx, tx, vote.SubjectURI) + if err != nil { + return false, err + } + intent, err := notifications.FanoutVoteCreate(ctx, c.notifications.LookupsTx(tx), c.bridgeTrust, notifications.VoteRecord{ + URI: vote.URI, VoterDID: vote.VoterDID, SubjectURI: vote.SubjectURI, SubjectRootURI: subjectRootURI, + Direction: vote.Direction, CreatedAt: vote.CreatedAt, VoterErased: voterErased, + }) + if err != nil { + return false, fmt.Errorf("compute vote notifications: %w", err) + } + if err := c.notifications.ApplyUpvoteGroupTx(ctx, tx, intent); err != nil { + return false, fmt.Errorf("write vote notifications: %w", err) + } + } // Commit transaction if err := tx.Commit(); err != nil { @@ -655,6 +741,19 @@ func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote * return true, nil // Vote was newly indexed } +// voteSubjectRootAfterCountUpdate reads a comment root under the row lock held +// by the count update, for the group bump on a newly indexed vote. +func voteSubjectRootAfterCountUpdate(ctx context.Context, tx *sql.Tx, subjectURI string) (string, error) { + if utils.ExtractCollectionFromURI(subjectURI) != CommentCollection { + return "", nil + } + var rootURI string + if err := tx.QueryRowContext(ctx, `SELECT root_uri FROM comments WHERE uri = $1`, subjectURI).Scan(&rootURI); err != nil { + return "", fmt.Errorf("read vote comment root after counting: %w", err) + } + return rootURI, nil +} + // subjectWasErased reports whether the repo HOSTING this subject carries a // migration-036 erasure marker. // diff --git a/internal/atproto/jetstream/vote_notification_erasure_test.go b/internal/atproto/jetstream/vote_notification_erasure_test.go new file mode 100644 index 0000000..a060ba2 --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_erasure_test.go @@ -0,0 +1,239 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func voteGroupResultsCleanup(t *testing.T, ctx context.Context, results <-chan error, started, finished *bool, operation string) { + t.Helper() + t.Cleanup(func() { + if *started && !*finished { + commentErasureResult(t, ctx, results, operation+" after fixture rollback") + } + }) +} + +func TestVoteConsumer_UpvoteGroupRootUsesLockedComment(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + secondRoot := fixture.addPost(t) + commentKey := testkit.TID() + commentURI := "at://" + fixture.author + "/" + CommentCollection + "/" + commentKey + _, err := fixture.db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'bafupvoteroot', $4, 'bafupvoteroot', 'comment', NOW())`, + commentURI, commentKey, fixture.author, fixture.post) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // registered before the open fixture transaction + event := upvoteGroupEvent(fixture.voter(t), commentURI, "up", fixture.createdAt, testkit.TID()) + consumerResults := make(chan error, 1) + consumerStarted, consumerFinished := false, false + voteGroupResultsCleanup(t, ctx, consumerResults, &consumerStarted, &consumerFinished, "HandleEvent(comment vote)") + transaction, fixtureProcessID := mentionEditRowTransaction(t, ctx, fixture.db) + var initialRoot string + require.NoError(t, transaction.QueryRowContext(ctx, + `SELECT root_uri FROM comments WHERE uri = $1 FOR UPDATE`, commentURI).Scan(&initialRoot)) + require.Equal(t, fixture.post, initialRoot, "fixture: the unlocked root is P1") + consumerStarted = true + go func() { consumerResults <- fixture.consumer().HandleEvent(ctx, event) }() + commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "comments") + _, err = transaction.ExecContext(ctx, `UPDATE comments SET root_uri = $1 WHERE uri = $2`, secondRoot, commentURI) + require.NoError(t, err) + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, consumerResults, "HandleEvent(comment vote)") + consumerFinished = true + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2 AND root_post_uri = $3`, + fixture.author, commentURI, secondRoot), + "the comment upvote group must navigate to P2, committed while the consumer waited on comments") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2 AND root_post_uri = $3`, + fixture.author, commentURI, fixture.post), "the stale unlocked root P1 must not be used") +} + +func TestVoteConsumer_UpvoteGroupDeleteFirstWaitsBeforeContent(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // registered before the open fixture transaction + deleteResults, consumerResults := make(chan error, 1), make(chan error, 1) + deleteStarted, deleteFinished := false, false + consumerStarted, consumerFinished := false, false + voteGroupResultsCleanup(t, ctx, deleteResults, &deleteStarted, &deleteFinished, "Delete(A)") + voteGroupResultsCleanup(t, ctx, consumerResults, &consumerStarted, &consumerFinished, "HandleEvent(A's vote)") + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, fixture.db, voter) + deleteStarted = true + go func() { deleteResults <- postgres.NewUserRepository(fixture.db).Delete(ctx, voter) }() + deleteProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "DELETE FROM users") + consumerStarted = true + event := upvoteGroupEvent(voter, fixture.post, "up", fixture.createdAt, testkit.TID()) + go func() { consumerResults <- fixture.consumer().HandleEvent(ctx, event) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + var waitingBeforeContent bool + err := transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid NOT IN ($1, $2) + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND NOT EXISTS (SELECT 1 FROM pg_locks content WHERE content.pid = waiter.pid + AND content.locktype = 'relation' + AND content.relation IN ('votes'::regclass, 'posts'::regclass, 'comments'::regclass)) + )`, deleteProcessID, fixtureProcessID).Scan(&waitingBeforeContent) + return waitingBeforeContent, err + }, testkit.WithDescription("vote consumer never blocked on Delete(A)'s advisory lock before votes/posts/comments")) + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, deleteResults, "Delete(A)") + deleteFinished = true + commentErasureResult(t, ctx, consumerResults, "HandleEvent(A's vote)") + consumerFinished = true + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "Delete-first voter erasure must leave no group for B's post") +} + +func TestVoteConsumer_UpvoteGroupEligibleVoterControl(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + deliverGroupVote(t, fixture.consumer(), fixture.voter(t), fixture.post, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "the same eligible voter without Delete must create the group") +} + +// Delete(B) removes the recipient's post before the recipient's notification. +// While the consumer holds the post row, Delete must therefore wait on that +// backend rather than reach the group row first and create a lock-order cycle. +func voteGroupDeleteWaitsForConsumer(t *testing.T, ctx context.Context, transaction *sql.Tx, consumerProcessID, fixtureProcessID int) { + t.Helper() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + var deleteBlockedOnPost bool + err := transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE datname = current_database() AND pid NOT IN ($1, $2) + AND wait_event_type = 'Lock' AND query ILIKE '%DELETE FROM posts%' + AND $1 = ANY(pg_blocking_pids(pid)) + )`, consumerProcessID, fixtureProcessID).Scan(&deleteBlockedOnPost) + return deleteBlockedOnPost, err + }, testkit.WithDescription("Delete(B) blocked behind the vote consumer while deleting B's posts")) +} + +func TestVoteConsumer_UpvoteGroupRecipientDeleteWaitsForConsumer(t *testing.T) { + t.Parallel() + for _, kind := range []string{"fresh_insert", "existing_group_bump"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + if kind == "existing_group_bump" { + deliverGroupVote(t, fixture.consumer(), fixture.voter(t), fixture.post, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "fixture: the first voter must create the group before the bump race") + } else { + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post)) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // registered before the open fixture transaction + consumerResults, deleteResults := make(chan error, 1), make(chan error, 1) + consumerStarted, consumerFinished := false, false + deleteStarted, deleteFinished := false, false + voteGroupResultsCleanup(t, ctx, deleteResults, &deleteStarted, &deleteFinished, "Delete(B)") + voteGroupResultsCleanup(t, ctx, consumerResults, &consumerStarted, &consumerFinished, "HandleEvent(B's post vote)") + transaction, fixtureProcessID := mentionEditRowTransaction(t, ctx, fixture.db) + if kind == "fresh_insert" { + var lockedDID string + require.NoError(t, transaction.QueryRowContext(ctx, + `SELECT did FROM users WHERE did = $1 FOR UPDATE`, fixture.author).Scan(&lockedDID)) + require.Equal(t, fixture.author, lockedDID) + } else { + var lockedID int64 + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT id FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2 FOR UPDATE`, + fixture.author, fixture.post).Scan(&lockedID)) + } + event := upvoteGroupEvent(fixture.voter(t), fixture.post, "up", fixture.createdAt, testkit.TID()) + consumerStarted = true + go func() { consumerResults <- fixture.consumer().HandleEvent(ctx, event) }() + consumerProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "INSERT INTO notifications") + deleteStarted = true + go func() { deleteResults <- postgres.NewUserRepository(fixture.db).Delete(ctx, fixture.author) }() + voteGroupDeleteWaitsForConsumer(t, ctx, transaction, consumerProcessID, fixtureProcessID) + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, consumerResults, "HandleEvent(B's post vote)") + consumerFinished = true + commentErasureResult(t, ctx, deleteResults, "Delete(B)") + deleteFinished = true + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE recipient_did = $1`, fixture.author), + "recipient erasure must remove the upvote group") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM users WHERE did = $1`, fixture.author), + "Delete(B) must remove the recipient") + }) + } +} + +// The erasure gate refuses any transaction that is not READ COMMITTED, so the +// vote consumer must request that isolation explicitly rather than inherit the +// session default. The clone's default is moved to REPEATABLE READ; only an +// explicit BeginTx option lets the qualifying upvote through the gate. +func TestVoteConsumer_UpvoteGroupGateRequestsReadCommittedExplicitly(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + ctx := context.Background() + var database string + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT current_database()`).Scan(&database)) + _, err := fixture.db.ExecContext(ctx, + "ALTER DATABASE "+pq.QuoteIdentifier(database)+" SET default_transaction_isolation = 'repeatable read'") + require.NoError(t, err) + // The new default reaches only new sessions. No pooled connection is idle + // after this, so every later transaction opens a fresh session. + fixture.db.SetMaxIdleConns(0) + control, err := fixture.db.BeginTx(ctx, nil) + require.NoError(t, err) + var defaultIsolation string + require.NoError(t, control.QueryRowContext(ctx, `SELECT current_setting('transaction_isolation')`).Scan(&defaultIsolation)) + require.NoError(t, control.Rollback()) + require.Equal(t, "repeatable read", defaultIsolation, + "control: a transaction begun without options must inherit the overridden default") + deliverGroupVote(t, fixture.consumer(), voter, fixture.post, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "the qualifying upvote must pass the erasure gate and create the group") +} + +// A vote's createdAt feeds the notification freshness window, and a far-future +// value would stay inside that window until wall-clock caught up. The vote is +// stored clamped to index time, the same clamp the post and comment consumers +// apply, so the value fan-out reads is never later than the index. +func TestVoteConsumer_FutureCreatedAtIsClampedToIndexTime(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + futureCreatedAt := time.Now().AddDate(10, 0, 0).UTC().Format(time.RFC3339) + event := upvoteGroupEvent(voter, fixture.post, "up", futureCreatedAt, testkit.TID()) + require.NoError(t, fixture.consumer().HandleEvent(context.Background(), event)) + handledAt := time.Now() + var storedCreatedAt time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT created_at FROM votes WHERE uri = $1`, + "at://"+voter+"/social.coves.feed.vote/"+event.Commit.RKey).Scan(&storedCreatedAt)) + require.False(t, storedCreatedAt.After(handledAt), + "a future vote createdAt must be clamped to index time: stored %s, HandleEvent returned at %s (record said %s)", + storedCreatedAt.UTC().Format(time.RFC3339Nano), handledAt.UTC().Format(time.RFC3339Nano), futureCreatedAt) +} diff --git a/internal/atproto/jetstream/vote_notification_group_bridged_test.go b/internal/atproto/jetstream/vote_notification_group_bridged_test.go new file mode 100644 index 0000000..ecaaf6c --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_group_bridged_test.go @@ -0,0 +1,70 @@ +//go:build integration + +package jetstream + +import ( + "context" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func (fixture upvoteGroupFixture) bridgedConsumer(options ...VoteEventConsumerOption) *VoteEventConsumer { + return fixture.consumer(append([]VoteEventConsumerOption{ + WithVoteNotifications(postgres.NewNotificationRepository(fixture.db, postgres.WithBridgedUpvoteTotals())), + }, options...)...) +} + +func TestVoteConsumer_BridgedGroupSurvivesLastNativeVoteRemoval(t *testing.T) { + t.Parallel() + for _, scenario := range []struct { + name string + bridgedUpvotes int + replacement bool + }{ + {"delete_with_bridged_total", 2, false}, + {"downvote_replacement_with_bridged_total", 2, true}, + {"delete_without_bridged_total", 0, false}, + {"downvote_replacement_without_bridged_total", 0, true}, + } { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.bridgedConsumer() + voter := fixture.voter(t) + key := testkit.TID() + original := maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revA, key) + voteURI := "at://" + voter + "/social.coves.feed.vote/" + key + require.NoError(t, consumer.HandleEvent(context.Background(), original)) + groupID, _ := maintenanceGroup(t, fixture) + at := time.Date(2026, time.September, 30, 12, 0, 7, 123456000, time.UTC).Truncate(time.Microsecond) + _, err := fixture.db.Exec(`UPDATE notifications SET sort_at = $2 WHERE id = $1`, groupID, at) + require.NoError(t, err) + _, err = fixture.db.Exec(`UPDATE posts SET bridged_upvote_count = $2, bridged_stats_as_of = NOW() WHERE uri = $1`, fixture.post, scenario.bridgedUpvotes) + require.NoError(t, err) + if scenario.replacement { + replaceMaintenanceVote(t, fixture, consumer, voter, "down", voteURI) + } else { + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent( + voter, "social.coves.feed.vote", "delete", key, revB, "", original.TimeUS+1_000_000, nil))) + exists, active := voteRowState(t, fixture.db, voteURI) + require.True(t, exists && !active, "the delete must soft-delete A's upvote") + } + if scenario.bridgedUpvotes == 0 { + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), "without native or bridged upvotes the group must be deleted") + return + } + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, fixture.author, fixture.post).Scan(&storedID, &storedSort), + "bridged upvotes must keep the same group after A's vote is withdrawn") + require.Equal(t, groupID, storedID) + require.True(t, storedSort.Equal(at), "withdrawing A's vote must not bump the group") + }) + } +} diff --git a/internal/atproto/jetstream/vote_notification_group_first_upvote_test.go b/internal/atproto/jetstream/vote_notification_group_first_upvote_test.go new file mode 100644 index 0000000..49c51eb --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_group_first_upvote_test.go @@ -0,0 +1,151 @@ +//go:build integration + +package jetstream + +import ( + "context" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestVoteConsumer_UpvoteGroupRepeatUpvoteAfterDeleteDoesNotBump(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + firstVoter, companion := fixture.voter(t), fixture.voter(t) + firstKey := testkit.TID() + firstURI := "at://" + firstVoter + "/social.coves.feed.vote/" + firstKey + first := maintenanceVoteAtKey(firstVoter, fixture.post, fixture.createdAt, revA, firstKey) + require.NoError(t, consumer.HandleEvent(context.Background(), first)) + companionURI := deliverGroupVote(t, consumer, companion, fixture.post, "up", fixture.createdAt) + groupID, _ := maintenanceGroup(t, fixture) + + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent( + firstVoter, "social.coves.feed.vote", "delete", firstKey, revB, "", first.TimeUS+1_000_000, nil))) + exists, active := voteRowState(t, fixture.db, firstURI) + require.True(t, exists && !active, "A's original upvote must remain as a soft-deleted row") + exists, active = voteRowState(t, fixture.db, companionURI) + require.True(t, exists && active, "D's upvote must keep the group alive") + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + past := ageMaintenanceGroup(t, fixture.db, groupID) + + secondKey := testkit.TID() + require.NotEqual(t, firstKey, secondKey, "A must use a new record key") + secondURI := "at://" + firstVoter + "/social.coves.feed.vote/" + secondKey + require.NoError(t, consumer.HandleEvent(context.Background(), maintenanceVoteAtKey( + firstVoter, fixture.post, time.Now().UTC().Format(time.RFC3339Nano), revC, secondKey))) + exists, active = voteRowState(t, fixture.db, secondURI) + require.True(t, exists && active, "A's new-rkey upvote must be indexed and live") + storedID, storedSort := maintenanceGroup(t, fixture) + require.Equal(t, groupID, storedID, "D's group must keep its original id") + require.Truef(t, storedSort.Equal(past), "A's second upvote moved sort_at from %s to %s", past, storedSort) +} + +func TestVoteConsumer_UpvoteGroupEarlierIneligibleUpvoteStillBlocksBump(t *testing.T) { + t.Parallel() + for _, gate := range []string{"pre_activation", "older_than_seven_days", "blocked", "recipient_ineligible"} { + t.Run(gate, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + createdAt := fixture.createdAt + switch gate { + case "pre_activation": + createdAt = ineligibleUpvoteTime(t, fixture, "before_activation", voter) + case "older_than_seven_days": + createdAt = ineligibleUpvoteTime(t, fixture, gate, voter) + case "blocked": + ineligibleUpvoteTime(t, fixture, "recipient_blocks_voter", voter) + case "recipient_ineligible": + _, err := fixture.db.Exec(`INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, 'Ineligible recipient', $2, 'bafupvoteservice')`, fixture.author, + "at://"+fixture.author+"/social.coves.aggregator.service/self") + require.NoError(t, err) + } + + firstKey := testkit.TID() + firstURI := "at://" + voter + "/social.coves.feed.vote/" + firstKey + first := maintenanceVoteAtKey(voter, fixture.post, createdAt, revA, firstKey) + require.NoError(t, consumer.HandleEvent(context.Background(), first)) + exists, active := voteRowState(t, fixture.db, firstURI) + require.True(t, exists && active, "A's ineligible first upvote must still be indexed") + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "%s must suppress the first upvote's group", gate) + + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent( + voter, "social.coves.feed.vote", "delete", firstKey, revB, "", first.TimeUS+1_000_000, nil))) + exists, active = voteRowState(t, fixture.db, firstURI) + require.True(t, exists && !active, "the first upvote must be retained as a soft-deleted row") + switch gate { + case "blocked": + result, err := fixture.db.Exec(`DELETE FROM user_blocks WHERE blocker_did = $1 AND blocked_did = $2`, fixture.author, voter) + require.NoError(t, err) + removed, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, removed, "B's block on A must be lifted") + case "recipient_ineligible": + result, err := fixture.db.Exec(`DELETE FROM aggregators WHERE did = $1`, fixture.author) + require.NoError(t, err) + removed, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, removed, "B's recipient ineligibility must be lifted") + } + + secondKey := testkit.TID() + require.NotEqual(t, firstKey, secondKey, "A's repeat upvote must have a new rkey") + secondURI := "at://" + voter + "/social.coves.feed.vote/" + secondKey + require.NoError(t, consumer.HandleEvent(context.Background(), maintenanceVoteAtKey( + voter, fixture.post, time.Now().UTC().Format(time.RFC3339Nano), revC, secondKey))) + exists, active = voteRowState(t, fixture.db, secondURI) + require.True(t, exists && active, "A's eligible repeat upvote must be indexed") + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "%s: A's earlier ineligible upvote must prevent a later group from being created", gate) + + controlURI := deliverGroupVote(t, consumer, fixture.voter(t), fixture.post, "up", fixture.createdAt) + exists, active = voteRowState(t, fixture.db, controlURI) + require.True(t, exists && active) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "%s: a different voter's first upvote must create B's group after eligibility is restored", gate) + }) + } +} + +func TestVoteConsumer_UpvoteGroupEarlierDownvoteDoesNotBlockBump(t *testing.T) { + t.Parallel() + for _, scenario := range []string{"deleted_downvote", "replaced_downvote"} { + t.Run(scenario, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + firstKey := testkit.TID() + first := upvoteGroupEvent(voter, fixture.post, "down", fixture.createdAt, revA) + first.Commit.RKey = firstKey + firstURI := "at://" + voter + "/social.coves.feed.vote/" + firstKey + require.NoError(t, consumer.HandleEvent(context.Background(), first)) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), "a downvote must not create a group") + if scenario == "deleted_downvote" { + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent( + voter, "social.coves.feed.vote", "delete", firstKey, revB, "", first.TimeUS+1_000_000, nil))) + exists, active := voteRowState(t, fixture.db, firstURI) + require.True(t, exists && !active, "the downvote must remain as a soft-deleted row") + secondKey := testkit.TID() + require.NotEqual(t, firstKey, secondKey) + secondURI := "at://" + voter + "/social.coves.feed.vote/" + secondKey + require.NoError(t, consumer.HandleEvent(context.Background(), maintenanceVoteAtKey( + voter, fixture.post, fixture.createdAt, revC, secondKey))) + exists, active = voteRowState(t, fixture.db, secondURI) + require.True(t, exists && active, "the first upvote must be indexed and live") + } else { + replaceMaintenanceVote(t, fixture, consumer, voter, "up", firstURI) + } + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "%s: an earlier downvote must not disqualify the voter's first upvote", scenario) + }) + } +} diff --git a/internal/atproto/jetstream/vote_notification_group_lock_test.go b/internal/atproto/jetstream/vote_notification_group_lock_test.go new file mode 100644 index 0000000..d588a50 --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_group_lock_test.go @@ -0,0 +1,119 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// At READ COMMITTED, a delete-if-empty that waits on a group row being bumped +// re-checks only that row after the bump commits, so it would delete a group +// whose new qualifying upvote is live. It is correct only because the consumer +// first takes the subject's posts row, which a concurrent vote's count update +// also holds. Here D's uncommitted vote holds P's row and the group row; A's +// replacement downvote must wait on P's row and then see D's upvote. +func TestVoteConsumer_UpvoteGroupDeleteIfEmptyWaitsOnSubjectRowLock(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + oldURI := deliverGroupVote(t, consumer, voter, fixture.post, "up", fixture.createdAt) + groupID, sortAt := maintenanceGroup(t, fixture) + companion := fixture.voter(t) + companionKey := testkit.TID() + companionURI := "at://" + companion + "/social.coves.feed.vote/" + companionKey + replacement := upvoteGroupEvent(voter, fixture.post, "down", fixture.createdAt, testkit.TID()) + replacementURI := "at://" + voter + "/social.coves.feed.vote/" + replacement.Commit.RKey + require.NotEqual(t, oldURI, replacementURI, "the replacement must have a new record key") + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // registered before the fixture transaction, which may still be open on failure + results := make(chan error, 1) + started, finished := false, false + voteGroupResultsCleanup(t, ctx, results, &started, &finished, "HandleEvent(A's replacement vote)") + transaction, fixtureProcessID := mentionEditRowTransaction(t, ctx, fixture.db) + _, err := transaction.ExecContext(ctx, `INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at) + VALUES ($1, 'bafupvotegroupvote', $2, $3, $4, 'bafupvotesubject', 'up', $5, NOW())`, + companionURI, companionKey, companion, fixture.post, fixture.createdAt) + require.NoError(t, err) + counted, err := transaction.ExecContext(ctx, `UPDATE posts + SET upvote_count = upvote_count + 1, + score = upvote_count + 1 - downvote_count + bridged_upvote_count - bridged_downvote_count + WHERE uri = $1`, fixture.post) + require.NoError(t, err) + countedRows, err := counted.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, countedRows, "D's count update must hold P's posts row") + require.NoError(t, postgres.NewNotificationRepository(fixture.db).ApplyUpvoteGroupTx(ctx, transaction, + notifications.UpvoteGroupIntent{ + Action: notifications.UpvoteGroupBump, + RecipientDID: fixture.author, SubjectURI: fixture.post, RootPostURI: fixture.post, + }), + "D's group bump must hold the group row") + + started = true + go func() { results <- consumer.HandleEvent(ctx, replacement) }() + // Wait for any block on the fixture, then record which row it is, so a + // consumer that reaches the group row first still runs to the lost group. + var waitingQuery string + var waitingOnPostsRow, waitingOnGroupRow bool + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + err := transaction.QueryRowContext(ctx, `SELECT waiter.query, + EXISTS (SELECT 1 FROM pg_locks row_lock WHERE row_lock.pid = waiter.pid + AND row_lock.locktype = 'tuple' AND row_lock.relation = 'posts'::regclass), + EXISTS (SELECT 1 FROM pg_locks row_lock WHERE row_lock.pid = waiter.pid + AND row_lock.locktype = 'tuple' AND row_lock.relation = 'notifications'::regclass) + FROM pg_stat_activity waiter + WHERE waiter.datname = current_database() AND waiter.pid <> $1 + AND waiter.wait_event_type = 'Lock' + AND $1 = ANY(pg_blocking_pids(waiter.pid)) + LIMIT 1`, fixtureProcessID).Scan(&waitingQuery, &waitingOnPostsRow, &waitingOnGroupRow) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("A's replacement vote blocked by D's uncommitted vote transaction")) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(A's replacement vote)") + finished = true + var storedID int64 + var storedSort time.Time + groupErr := fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + fixture.author, fixture.post).Scan(&storedID, &storedSort) + assert.NoError(t, groupErr, "group deleted although D's committed qualifying upvote is live") + assert.True(t, waitingOnPostsRow, "A's replacement must wait on P's posts row") + assert.False(t, waitingOnGroupRow, "A's replacement must not reach the group row while D holds P's row") + assert.Contains(t, waitingQuery, "UPDATE posts SET upvote_count = GREATEST(0, upvote_count - 1)", + "the stale-vote decrement is the replacement's first lock on P's row") + require.NoError(t, groupErr) + assert.Equal(t, groupID, storedID, "the surviving group must be the original row") + assert.False(t, storedSort.Before(sortAt), "the group's sort_at must not move backwards") + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + + exists, active := voteRowState(t, fixture.db, oldURI) + assert.True(t, exists && !active, "A's old upvote must be soft-deleted") + exists, active = voteRowState(t, fixture.db, replacementURI) + assert.True(t, exists && active, "A's replacement downvote must be live") + exists, active = voteRowState(t, fixture.db, companionURI) + assert.True(t, exists && active, "D's upvote must be live") + counts := readSubjectCounts(t, fixture.db, `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post) + assert.Equal(t, 1, counts.Upvotes, "D's increment and A's stale decrement must both apply") + assert.Equal(t, 1, counts.Downvotes, "A's replacement downvote must be counted") +} diff --git a/internal/atproto/jetstream/vote_notification_group_maintenance_test.go b/internal/atproto/jetstream/vote_notification_group_maintenance_test.go new file mode 100644 index 0000000..c97cd30 --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_group_maintenance_test.go @@ -0,0 +1,550 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func maintenanceGroup(t *testing.T, fixture upvoteGroupFixture) (int64, time.Time) { + t.Helper() + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "the group must exist before the vote set changes") + var groupID int64 + var sortAt time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + fixture.author, fixture.post).Scan(&groupID, &sortAt)) + return groupID, sortAt +} + +func ageMaintenanceGroup(t *testing.T, db *sql.DB, groupID int64) time.Time { + t.Helper() + past := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Microsecond) + result, err := db.Exec(`UPDATE notifications SET sort_at = $1 WHERE id = $2`, past, groupID) + require.NoError(t, err) + updated, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, updated) + return past +} + +func replaceMaintenanceVote(t *testing.T, fixture upvoteGroupFixture, consumer *VoteEventConsumer, voter, direction, oldURI string) { + t.Helper() + event := upvoteGroupEvent(voter, fixture.post, direction, fixture.createdAt, testkit.TID()) + newURI := "at://" + voter + "/social.coves.feed.vote/" + event.Commit.RKey + require.NotEqual(t, oldURI, newURI, "replacement must use a new record key") + require.NoError(t, consumer.HandleEvent(context.Background(), event)) + exists, active := voteRowState(t, fixture.db, oldURI) + require.True(t, exists, "the old vote must still exist as a soft-deleted row") + require.False(t, active, "the old vote must be soft-deleted by the replacement") + exists, active = voteRowState(t, fixture.db, newURI) + require.True(t, exists && active, "the replacement vote must be live") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM votes + WHERE uri = $1 AND direction = $2 AND deleted_at IS NULL`, newURI, direction), + "the replacement must have the requested direction") +} + +func TestVoteConsumer_UpvoteGroupMaintenanceBlockedLastVoterDownvote(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + oldURI := deliverGroupVote(t, consumer, voter, fixture.post, "up", fixture.createdAt) + maintenanceGroup(t, fixture) + ineligibleUpvoteTime(t, fixture, "recipient_blocks_voter", voter) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM votes + WHERE uri = $1 AND direction = 'up' AND deleted_at IS NULL`, oldURI), + "the blocked voter still has a live upvote; the block makes it ineligible") + otherVoter := fixture.voter(t) + deliverGroupVote(t, consumer, otherVoter, fixture.post, "down", fixture.createdAt) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "group deleted: a downvote cannot keep a group whose only upvoter is blocked") +} + +func TestVoteConsumer_UpvoteGroupMaintenanceDownvoteReplacement(t *testing.T) { + t.Parallel() + for _, companion := range []bool{false, true} { + name := "last_qualifying_upvote_deleted" + if companion { + name = "other_qualifying_upvote_preserves_group_and_sort" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + oldURI := deliverGroupVote(t, consumer, voter, fixture.post, "up", fixture.createdAt) + var otherURI string + if companion { + otherURI = deliverGroupVote(t, consumer, fixture.voter(t), fixture.post, "up", fixture.createdAt) + } + groupID, _ := maintenanceGroup(t, fixture) + var past time.Time + if companion { + past = ageMaintenanceGroup(t, fixture.db, groupID) + } + replaceMaintenanceVote(t, fixture, consumer, voter, "down", oldURI) + if !companion { + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "group deleted: replacing the last qualifying upvote with a downvote leaves no voters") + return + } + exists, active := voteRowState(t, fixture.db, otherURI) + require.True(t, exists && active, "the other voter's qualifying upvote must still be live") + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + fixture.author, fixture.post).Scan(&storedID, &storedSort), + "the other voter's live upvote must keep the group") + assert.Equal(t, groupID, storedID, "the surviving group must be the original row") + assert.True(t, storedSort.Equal(past), "a downvote replacement must not bump the surviving group") + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + }) + } +} + +func TestVoteConsumer_UpvoteGroupMaintenanceUpvoteReplacement(t *testing.T) { + t.Parallel() + for _, scenario := range []struct { + name string + blocked bool + companion bool + }{ + {"blocked_last_qualifying_upvote_deleted", true, false}, + {"blocked_with_other_upvote_preserves_group_and_sort", true, true}, + {"eligible_replacement_keeps_group_without_bump", false, false}, + } { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + oldURI := deliverGroupVote(t, consumer, voter, fixture.post, "up", fixture.createdAt) + var otherURI string + if scenario.companion { + otherURI = deliverGroupVote(t, consumer, fixture.voter(t), fixture.post, "up", fixture.createdAt) + } + groupID, _ := maintenanceGroup(t, fixture) + past := ageMaintenanceGroup(t, fixture.db, groupID) + if scenario.blocked { + ineligibleUpvoteTime(t, fixture, "recipient_blocks_voter", voter) + } + replaceMaintenanceVote(t, fixture, consumer, voter, "up", oldURI) + if scenario.blocked && !scenario.companion { + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "group deleted: blocked up-to-up replacement leaves no qualifying upvotes") + return + } + if scenario.companion { + exists, active := voteRowState(t, fixture.db, otherURI) + require.True(t, exists && active, "the other voter's qualifying upvote must still be live") + } + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + fixture.author, fixture.post).Scan(&storedID, &storedSort), + "the group must survive while a qualifying upvote exists") + assert.Equal(t, groupID, storedID, "replacement must retain the same group id") + if scenario.blocked { + assert.True(t, storedSort.Equal(past), "blocked replacement must not bump a surviving group") + } else { + assert.True(t, storedSort.Equal(past), "eligible up-to-up replacement must keep the group's sort_at unchanged") + } + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + }) + } +} + +func TestVoteConsumer_UpvoteGroupMaintenanceErasedVoter(t *testing.T) { + t.Parallel() + for _, direction := range []string{"up", "down"} { + t.Run("later_"+direction+"vote", func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + original := upvoteGroupEvent(voter, fixture.post, "up", fixture.createdAt, revA) + originalURI := "at://" + voter + "/social.coves.feed.vote/" + original.Commit.RKey + require.NoError(t, consumer.HandleEvent(context.Background(), original)) + groupID, _ := maintenanceGroup(t, fixture) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM votes + WHERE uri = $1 AND deleted_at IS NULL`, originalURI), "A must be the group's only voter") + + require.NoError(t, postgres.NewUserRepository(fixture.db).Delete(context.Background(), voter), + "erase A through the user repository's real deletion path") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, voter), + "the erasure gate must see A's marker") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE voter_did = $1`, voter), + "erasure must hard-delete A's original vote") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE id = $1`, groupID), + "the group must survive erasure itself for the later vote to maintain it") + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, originalURI, revA), + "erasure must leave the original rev claim to reject an equal-rev replay") + require.NoError(t, consumer.HandleEvent(context.Background(), original), "replay A's original event at the same rev") + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, originalURI), + "the rev-gated replay must not restore A's hard-deleted vote") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE id = $1`, groupID), + "the rev-gated replay must not delete the group") + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + + later := upvoteGroupEvent(voter, fixture.post, direction, fixture.createdAt, revB) + laterURI := "at://" + voter + "/social.coves.feed.vote/" + later.Commit.RKey + require.NotEqual(t, originalURI, laterURI, "the later vote must use a new record key") + require.NoError(t, consumer.HandleEvent(context.Background(), later)) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM votes + WHERE uri = $1 AND voter_did = $2 AND subject_uri = $3 AND direction = $4 AND deleted_at IS NULL`, + laterURI, voter, fixture.post, direction), "the erased voter's later vote must be accepted") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, laterURI, revB), "the new record's newer rev must be claimed") + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "group deleted: A's later vote is not qualifying after erasure, regardless of direction") + }) + } +} + +func maintenanceVoteAtKey(voter, subject, createdAt, rev, recordKey string) *JetstreamEvent { + event := upvoteGroupEvent(voter, subject, "up", createdAt, rev) + event.Commit.RKey = recordKey + return event +} + +func seedMaintenanceReplacementConflict(t *testing.T, fixture upvoteGroupFixture, consumer *VoteEventConsumer, voter, recipient, subject string) (*JetstreamEvent, string, string) { + t.Helper() + secondKey := testkit.TID() + firstKey := testkit.TID() + require.NotEqual(t, secondKey, firstKey) + secondURI := "at://" + voter + "/social.coves.feed.vote/" + secondKey + firstURI := "at://" + voter + "/social.coves.feed.vote/" + firstKey + + require.NoError(t, consumer.HandleEvent(context.Background(), maintenanceVoteAtKey(voter, subject, fixture.createdAt, revA, secondKey))) + require.Equal(t, 1, groupCount(t, fixture.db, recipient, subject), "the first upvote must create the group") + require.NoError(t, consumer.HandleEvent(context.Background(), maintenanceVoteAtKey(voter, subject, fixture.createdAt, revB, firstKey))) + exists, active := voteRowState(t, fixture.db, secondURI) + require.True(t, exists && !active, "rkey2 must already be soft-deleted before the conflicting create") + exists, active = voteRowState(t, fixture.db, firstURI) + require.True(t, exists && active, "rkey1 must be live before the conflicting create") + require.Equal(t, 1, groupCount(t, fixture.db, recipient, subject), "the group must exist before the conflicting create") + return maintenanceVoteAtKey(voter, subject, fixture.createdAt, revC, secondKey), secondURI, firstURI +} + +func TestVoteConsumer_UpvoteGroupMaintenanceReplacementConflictDeletesLastUpvote(t *testing.T) { + t.Parallel() + for _, kind := range []string{"postv2", "comment", "legacy"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + recipient, subject, countQuery := fixture.author, fixture.post, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1` + switch kind { + case "comment": + id := testkit.UniqueID(t) + recipient = "did:plc:" + id + "commenter" + insertBridgedUserOnPDS(t, fixture.db, recipient, id+"commenter.test", bridgedTestNativePDS) + key := testkit.TID() + subject = "at://" + recipient + "/" + CommentCollection + "/" + key + _, err := fixture.db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'bafupvoteroot', $4, 'bafupvoteroot', 'comment', NOW())`, + subject, key, recipient, fixture.post) + require.NoError(t, err) + countQuery = `SELECT upvote_count, downvote_count, score FROM comments WHERE uri = $1` + case "legacy": + key := testkit.TID() + subject = "at://" + fixture.community + "/social.coves.community.post/" + key + _, err := fixture.db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'legacy post', NOW())`, + subject, key, recipient, fixture.community) + require.NoError(t, err) + } + consumer := fixture.consumer() + conflict, secondURI, firstURI := seedMaintenanceReplacementConflict(t, fixture, consumer, + fixture.voter(t), recipient, subject) + before := readSubjectCounts(t, fixture.db, countQuery, subject) + require.Equal(t, 1, before.Upvotes, "rkey1 must be the last live upvote before the conflict") + require.NoError(t, consumer.HandleEvent(context.Background(), conflict)) + for _, uri := range []string{secondURI, firstURI} { + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists && !active, "both vote rows must exist and be soft-deleted after the conflict: %s", uri) + } + after := readSubjectCounts(t, fixture.db, countQuery, subject) + require.Equal(t, before.Upvotes-1, after.Upvotes, "the conflict must subtract rkey1's upvote exactly once") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, secondURI, revC), "rkey2's rev must advance to r3") + require.Zero(t, groupCount(t, fixture.db, recipient, subject), + "group deleted: the conflicting create removed the last qualifying upvote") + }) + } +} + +func TestVoteConsumer_UpvoteGroupMaintenanceReplacementConflictPreservesCompanion(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + conflict, secondURI, firstURI := seedMaintenanceReplacementConflict(t, fixture, consumer, + voter, fixture.author, fixture.post) + companion := fixture.voter(t) + companionURI := deliverGroupVote(t, consumer, companion, fixture.post, "up", fixture.createdAt) + groupID, _ := maintenanceGroup(t, fixture) + past := ageMaintenanceGroup(t, fixture.db, groupID) + before := readSubjectCounts(t, fixture.db, `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post) + require.Equal(t, 2, before.Upvotes) + require.NoError(t, consumer.HandleEvent(context.Background(), conflict)) + for _, uri := range []string{secondURI, firstURI} { + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists && !active, "the conflict must leave both of A's vote rows soft-deleted") + } + exists, active := voteRowState(t, fixture.db, companionURI) + require.True(t, exists && active, "D's independent qualifying upvote must remain live") + after := readSubjectCounts(t, fixture.db, `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post) + require.Equal(t, before.Upvotes-1, after.Upvotes) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, secondURI, revC)) + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, fixture.author, fixture.post).Scan(&storedID, &storedSort), + "D's qualifying upvote must keep the original group") + require.Equal(t, groupID, storedID) + require.Truef(t, storedSort.Equal(past), "the conflicting create changed sort_at from %s to %s", past, storedSort) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + // A later real withdrawal is the positive control for the group's survival while D remains. + replaceMaintenanceVote(t, fixture, consumer, companion, "down", companionURI) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "positive control: removing D's last qualifying upvote deletes the group") +} + +func TestVoteConsumer_UpvoteGroupMaintenanceReplacementConflictWithoutStaleVoteLeavesGroup(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + require.NoError(t, consumer.HandleEvent(context.Background(), maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revA, key))) + groupID, _ := maintenanceGroup(t, fixture) + ineligibleUpvoteTime(t, fixture, "recipient_blocks_voter", voter) + past := ageMaintenanceGroup(t, fixture.db, groupID) + require.NoError(t, consumer.HandleEvent(context.Background(), maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revB, key))) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, uri, revB), "the same-rkey create must win the rev gate") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM votes + WHERE uri = $1 AND deleted_at IS NULL`, uri), "no stale vote was soft-deleted") + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, fixture.author, fixture.post).Scan(&storedID, &storedSort)) + require.Equal(t, groupID, storedID, "a conflict without a vote-set change must keep the group") + require.Truef(t, storedSort.Equal(past), "a conflict without a vote-set change changed sort_at from %s to %s", past, storedSort) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + // A new-rkey vote changes the set and proves the blocked-voter group can be deleted in this fixture. + fresh := maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revC, testkit.TID()) + require.NotEqual(t, key, fresh.Commit.RKey) + require.NoError(t, consumer.HandleEvent(context.Background(), fresh)) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "positive control: a real replacement of the blocked voter's upvote deletes the group") +} + +func TestVoteConsumer_UpvoteGroupMaintenanceRollbackReplacementConflictEarlyCommit(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + workingConsumer := fixture.consumer() + conflict, secondURI, firstURI := seedMaintenanceReplacementConflict(t, fixture, workingConsumer, + fixture.voter(t), fixture.author, fixture.post) + groupID, sortAt := maintenanceGroup(t, fixture) + countQuery := `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1` + before := readSubjectCounts(t, fixture.db, countQuery, fixture.post) + require.Equal(t, 1, before.Upvotes, "rkey1 must be the last live upvote") + + injected := errors.New("injected after real replacement-conflict group deletion") + repository := &deleteThenFailUpvoteGroupRepository{ + Repository: postgres.NewNotificationRepository(fixture.db), failure: injected, + } + err := fixture.consumer(WithVoteNotifications(repository)).HandleEvent(context.Background(), conflict) + require.ErrorIs(t, err, injected) + require.ErrorContains(t, err, "write stale vote notifications", + "the early commit must name its own group write, distinct from the final commit's") + require.Equal(t, []notifications.UpvoteGroupIntent{{ + Action: notifications.UpvoteGroupDeleteIfEmpty, RecipientDID: fixture.author, SubjectURI: fixture.post, + }}, repository.intents, "the early-commit path must request deletion for the post author") + require.True(t, repository.deletedInsideTransaction, "the real repository must delete the group inside the aborted transaction") + exists, active := voteRowState(t, fixture.db, firstURI) + assert.True(t, exists && active, "rkey1's stale-vote soft delete must roll back") + exists, active = voteRowState(t, fixture.db, secondURI) + assert.True(t, exists && !active, "rkey2 must remain soft-deleted") + assert.Equal(t, before, readSubjectCounts(t, fixture.db, countQuery, fixture.post), + "the stale-vote count decrement must roll back") + var storedRev string + require.NoError(t, fixture.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, secondURI).Scan(&storedRev)) + assert.Equal(t, revA, storedRev, "the r3 rev claim must roll back") + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, fixture.author, fixture.post).Scan(&storedID, &storedSort)) + assert.Equal(t, groupID, storedID) + assert.True(t, storedSort.Equal(sortAt), "the original group must survive the failed attempt") + + require.NoError(t, workingConsumer.HandleEvent(context.Background(), conflict), "identical r3 redrive must succeed") + for _, uri := range []string{firstURI, secondURI} { + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists && !active, "redrive must soft-delete both vote rows: %s", uri) + } + assert.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "positive control: the same conflict deletes the last-voter group") + assert.Equal(t, before.Upvotes-1, readSubjectCounts(t, fixture.db, countQuery, fixture.post).Upvotes) + require.NoError(t, fixture.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, secondURI).Scan(&storedRev)) + assert.Equal(t, revC, storedRev) +} + +type deleteThenFailUpvoteGroupRepository struct { + notifications.Repository + failure error + intents []notifications.UpvoteGroupIntent + deletedInsideTransaction bool +} + +func (repository *deleteThenFailUpvoteGroupRepository) ApplyUpvoteGroupTx(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + repository.intents = append(repository.intents, intent) + if err := repository.Repository.ApplyUpvoteGroupTx(ctx, tx, intent); err != nil { + return err + } + var remaining int + if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + intent.RecipientDID, intent.SubjectURI).Scan(&remaining); err != nil { + return err + } + repository.deletedInsideTransaction = remaining == 0 + return repository.failure +} + +func TestVoteConsumer_UpvoteGroupMaintenanceRollbackFinalCommitRealDeletion(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + workingConsumer := fixture.consumer() + voter := fixture.voter(t) + oldURI := deliverGroupVote(t, workingConsumer, voter, fixture.post, "up", fixture.createdAt) + groupID, sortAt := maintenanceGroup(t, fixture) + countQuery := `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1` + before := readSubjectCounts(t, fixture.db, countQuery, fixture.post) + require.Equal(t, 1, before.Upvotes, "A must be the group's only live upvoter") + downvote := upvoteGroupEvent(voter, fixture.post, "down", fixture.createdAt, testkit.TID()) + downvoteURI := "at://" + voter + "/social.coves.feed.vote/" + downvote.Commit.RKey + require.NotEqual(t, oldURI, downvoteURI, "replacement must use a new record key") + + injected := errors.New("injected after real group deletion") + repository := &deleteThenFailUpvoteGroupRepository{ + Repository: postgres.NewNotificationRepository(fixture.db), failure: injected, + } + err := fixture.consumer(WithVoteNotifications(repository)).HandleEvent(context.Background(), downvote) + require.ErrorIs(t, err, injected) + require.Equal(t, []notifications.UpvoteGroupIntent{{ + Action: notifications.UpvoteGroupDeleteIfEmpty, RecipientDID: fixture.author, SubjectURI: fixture.post, + }}, repository.intents, "the final-commit path must request deletion for the post author") + require.True(t, repository.deletedInsideTransaction, "the real repository must delete the group inside the aborted transaction") + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, fixture.author, fixture.post).Scan(&storedID, &storedSort)) + assert.Equal(t, groupID, storedID, "rollback must restore the original group row") + assert.True(t, storedSort.Equal(sortAt), "rollback must restore the original group sort_at") + exists, active := voteRowState(t, fixture.db, oldURI) + assert.True(t, exists && active, "A's original upvote must remain live") + assert.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, downvoteURI), + "the replacement downvote insert must roll back") + assert.Equal(t, before, readSubjectCounts(t, fixture.db, countQuery, fixture.post), + "the replacement's count mutations must roll back") + assert.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs WHERE record_uri = $1`, downvoteURI), + "the new record's rev claim must roll back") + + require.NoError(t, workingConsumer.HandleEvent(context.Background(), downvote), "identical downvote redrive must succeed") + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "positive control: the same replacement deletes the last-voter group") + exists, active = voteRowState(t, fixture.db, oldURI) + require.True(t, exists && !active, "the original upvote must be soft-deleted after redrive") + exists, active = voteRowState(t, fixture.db, downvoteURI) + require.True(t, exists && active, "the downvote must be live after redrive") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, downvoteURI, downvote.Commit.Rev)) +} + +func TestVoteConsumer_UpvoteGroupMaintenanceReplacementDeleteFirstWaitsOnErasureLock(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + consumer := fixture.consumer() + oldURI := deliverGroupVote(t, consumer, voter, fixture.post, "up", fixture.createdAt) + maintenanceGroup(t, fixture) + replacement := upvoteGroupEvent(voter, fixture.post, "down", fixture.createdAt, testkit.TID()) + replacementURI := "at://" + voter + "/social.coves.feed.vote/" + replacement.Commit.RKey + require.NotEqual(t, oldURI, replacementURI, "the replacement must have a new record key") + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // registered before the fixture transaction, which may still be open on failure + results := make(chan error, 1) + started, finished := false, false + voteGroupResultsCleanup(t, ctx, results, &started, &finished, "HandleEvent(A's replacement vote)") + transaction, fixtureProcessID := mentionEditRowTransaction(t, ctx, fixture.db) + _, err := transaction.ExecContext(ctx, "SELECT pg_advisory_xact_lock("+postgres.ErasureLockKeySQL+")", voter) + require.NoError(t, err) + _, err = transaction.ExecContext(ctx, `INSERT INTO deleted_accounts (did) VALUES ($1)`, voter) + require.NoError(t, err) + deleted, err := transaction.ExecContext(ctx, `DELETE FROM votes WHERE voter_did = $1`, voter) + require.NoError(t, err) + deletedRows, err := deleted.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, deletedRows, "the open erasure transaction must hold A's old vote row") + + started = true + go func() { results <- consumer.HandleEvent(ctx, replacement) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + var waitingBeforeContent bool + err := transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid <> $1 + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND $1 = ANY(pg_blocking_pids(waiter.pid)) + AND NOT EXISTS (SELECT 1 FROM pg_locks other WHERE other.pid = waiter.pid + AND NOT other.granted AND other.locktype IN ('tuple', 'transactionid')) + AND NOT EXISTS (SELECT 1 FROM pg_locks content WHERE content.pid = waiter.pid + AND content.locktype = 'relation' AND content.relation IN + ('votes'::regclass, 'posts'::regclass, 'comments'::regclass)) + )`, fixtureProcessID).Scan(&waitingBeforeContent) + return waitingBeforeContent, err + }, testkit.WithDescription("replacement consumer waiting for A's erasure advisory lock before any vote/post/comment row lock")) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(A's replacement vote)") + finished = true + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, voter)) + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, oldURI), + "A's upvote must be hard-deleted by the fixture erasure") + exists, active := voteRowState(t, fixture.db, replacementURI) + require.True(t, exists && active, "the replacement must finish indexing after the erasure lock is released") + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "A is erased and has no qualifying upvote, so the old group must be deleted") +} diff --git a/internal/atproto/jetstream/vote_notification_group_retraction_branches_test.go b/internal/atproto/jetstream/vote_notification_group_retraction_branches_test.go new file mode 100644 index 0000000..1940f89 --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_group_retraction_branches_test.go @@ -0,0 +1,225 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func blockedVoteGroup(t *testing.T) (upvoteGroupFixture, *VoteEventConsumer, string, string, *JetstreamEvent, int64, time.Time) { + t.Helper() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + created := maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revB, key) + require.NoError(t, consumer.HandleEvent(context.Background(), created)) + groupID, sortAt := maintenanceGroup(t, fixture) + ineligibleUpvoteTime(t, fixture, "recipient_blocks_voter", voter) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM user_blocks + WHERE blocker_did = $1 AND blocked_did = $2`, fixture.author, voter)) + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists && active, "fixture: A's blocked upvote must still be live") + return fixture, consumer, voter, uri, created, groupID, sortAt +} + +func requireVoteGroupUnchanged(t *testing.T, fixture upvoteGroupFixture, groupID int64, sortAt time.Time) { + t.Helper() + storedID, storedSort := maintenanceGroup(t, fixture) + require.Equal(t, groupID, storedID, "the original group row must survive") + require.Truef(t, storedSort.Equal(sortAt), "group sort_at changed from %s to %s", sortAt, storedSort) +} + +func TestVoteConsumer_UpvoteGroupDeleteVoteFailureRollsBack(t *testing.T) { + t.Parallel() + fixture, workingConsumer, voter, uri, created, groupID, sortAt := blockedVoteGroup(t) + countQuery := `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1` + before := readSubjectCounts(t, fixture.db, countQuery, fixture.post) + require.Equal(t, 1, before.Upvotes) + injected := errors.New("injected vote deletion group write failure") + repository := &failingUpvoteGroupRepository{Repository: postgres.NewNotificationRepository(fixture.db), failure: injected} + deleted := revCommitEvent(voter, "social.coves.feed.vote", "delete", created.Commit.RKey, revC, "", created.TimeUS+1_000_000, nil) + err := fixture.consumer(WithVoteNotifications(repository)).HandleEvent(context.Background(), deleted) + require.ErrorIs(t, err, injected) + require.Equal(t, []notifications.UpvoteGroupIntent{{Action: notifications.UpvoteGroupDeleteIfEmpty, + RecipientDID: fixture.author, SubjectURI: fixture.post}}, repository.intents) + exists, active := voteRowState(t, fixture.db, uri) + assert.True(t, exists && active, "the failed delete must leave the vote live") + assert.Equal(t, before, readSubjectCounts(t, fixture.db, countQuery, fixture.post), "count decrement must roll back") + assert.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, uri, revB), "delete rev claim must roll back to the create rev") + requireVoteGroupUnchanged(t, fixture, groupID, sortAt) + require.NoError(t, workingConsumer.HandleEvent(context.Background(), deleted), "identical redrive must succeed") + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), "positive control: the blocked vote leaves no qualifying upvotes") +} + +func TestVoteConsumer_UpvoteGroupDeleteVoteNoOpBranchesLeaveGroup(t *testing.T) { + t.Parallel() + for _, scenario := range []string{"stale_rev", "already_deleted", "not_found"} { + t.Run(scenario, func(t *testing.T) { + t.Parallel() + fixture, consumer, voter, uri, created, groupID, sortAt := blockedVoteGroup(t) + key, rev := created.Commit.RKey, revC + switch scenario { + case "stale_rev": + rev = revA + case "already_deleted": + result, err := fixture.db.Exec(`UPDATE votes SET deleted_at = NOW() WHERE uri = $1`, uri) + require.NoError(t, err) + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + case "not_found": + key = testkit.TID() + require.NotEqual(t, created.Commit.RKey, key) + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM votes + WHERE voter_did = $1 AND rkey = $2`, voter, key)) + } + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent( + voter, "social.coves.feed.vote", "delete", key, rev, "", created.TimeUS+1_000_000, nil))) + requireVoteGroupUnchanged(t, fixture, groupID, sortAt) + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists) + if scenario == "already_deleted" { + require.False(t, active) + } else { + require.True(t, active, "the no-op must leave A's vote live") + } + require.Equal(t, 1, readSubjectCounts(t, fixture.db, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post).Upvotes) + }) + } +} + +func TestVoteConsumer_UpvoteGroupDeleteVoteZeroRowSoftDeleteLeavesGroup(t *testing.T) { + t.Parallel() + fixture, consumer, voter, uri, created, groupID, sortAt := blockedVoteGroup(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + results := make(chan error, 1) + started, finished := false, false + voteGroupResultsCleanup(t, ctx, results, &started, &finished, "HandleEvent(A's zero-row vote delete)") + transaction, fixtureProcessID := mentionEditRowTransaction(t, ctx, fixture.db) + result, err := transaction.ExecContext(ctx, `UPDATE votes SET deleted_at = NOW() WHERE uri = $1`, uri) + require.NoError(t, err) + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows, "fixture must hold A's vote row") + started = true + go func() { + results <- consumer.HandleEvent(ctx, revCommitEvent(voter, "social.coves.feed.vote", "delete", + created.Commit.RKey, revC, "", created.TimeUS+1_000_000, nil)) + }() + commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "UPDATE votes") + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(A's zero-row vote delete)") + finished = true + requireVoteGroupUnchanged(t, fixture, groupID, sortAt) + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists && !active) + require.Equal(t, 1, readSubjectCounts(t, fixture.db, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post).Upvotes, + "the consumer must not decrement a vote already soft-deleted by the fixture") +} + +func TestVoteConsumer_UpvoteGroupDeleteVoteWaitsOnSubjectRowBeforeMaintenance(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + created := maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revA, key) + require.NoError(t, consumer.HandleEvent(context.Background(), created)) + groupID, sortAt := maintenanceGroup(t, fixture) + companion := fixture.voter(t) + companionKey := testkit.TID() + companionURI := "at://" + companion + "/social.coves.feed.vote/" + companionKey + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + results := make(chan error, 1) + started, finished := false, false + voteGroupResultsCleanup(t, ctx, results, &started, &finished, "HandleEvent(A's vote delete)") + transaction, fixtureProcessID := mentionEditRowTransaction(t, ctx, fixture.db) + _, err := transaction.ExecContext(ctx, `INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at) + VALUES ($1, 'bafupvotegroupvote', $2, $3, $4, 'bafupvotesubject', 'up', $5, NOW())`, + companionURI, companionKey, companion, fixture.post, fixture.createdAt) + require.NoError(t, err) + counted, err := transaction.ExecContext(ctx, `UPDATE posts + SET upvote_count = upvote_count + 1, + score = upvote_count + 1 - downvote_count + bridged_upvote_count - bridged_downvote_count + WHERE uri = $1`, fixture.post) + require.NoError(t, err) + countedRows, err := counted.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, countedRows, "D's count update must hold the posts row") + require.NoError(t, postgres.NewNotificationRepository(fixture.db).ApplyUpvoteGroupTx(ctx, transaction, + notifications.UpvoteGroupIntent{Action: notifications.UpvoteGroupBump, + RecipientDID: fixture.author, SubjectURI: fixture.post, RootPostURI: fixture.post}), + "D's group bump must hold the group row") + started = true + go func() { + results <- consumer.HandleEvent(ctx, revCommitEvent(voter, "social.coves.feed.vote", "delete", + key, revB, "", created.TimeUS+1_000_000, nil)) + }() + var waitingQuery string + var waitingOnPostsRow, waitingOnGroupRow bool + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + err := transaction.QueryRowContext(ctx, `SELECT waiter.query, + EXISTS (SELECT 1 FROM pg_locks row_lock WHERE row_lock.pid = waiter.pid + AND row_lock.locktype = 'tuple' AND row_lock.relation = 'posts'::regclass), + EXISTS (SELECT 1 FROM pg_locks row_lock WHERE row_lock.pid = waiter.pid + AND row_lock.locktype = 'tuple' AND row_lock.relation = 'notifications'::regclass) + FROM pg_stat_activity waiter + WHERE waiter.datname = current_database() AND waiter.pid <> $1 + AND waiter.wait_event_type = 'Lock' + AND $1 = ANY(pg_blocking_pids(waiter.pid)) + LIMIT 1`, fixtureProcessID).Scan(&waitingQuery, &waitingOnPostsRow, &waitingOnGroupRow) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("A's vote delete blocked by D's uncommitted post vote")) + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, results, "HandleEvent(A's vote delete)") + finished = true + var storedID int64 + var storedSort time.Time + groupErr := fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + fixture.author, fixture.post).Scan(&storedID, &storedSort) + assert.NoError(t, groupErr, "group deleted although D's committed qualifying upvote is live") + assert.True(t, waitingOnPostsRow, "A's delete must wait on the posts row") + assert.False(t, waitingOnGroupRow, "A's delete must not reach the group row while D holds the post") + assert.Contains(t, waitingQuery, "UPDATE posts", "A's count decrement must block before group maintenance") + assert.Contains(t, waitingQuery, "upvote_count = GREATEST(0, upvote_count - 1)") + require.NoError(t, groupErr) + assert.Equal(t, groupID, storedID) + assert.False(t, storedSort.Before(sortAt), "the group sort_at must not move backwards") + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + exists, active := voteRowState(t, fixture.db, uri) + assert.True(t, exists && !active, "A's upvote must be soft-deleted") + exists, active = voteRowState(t, fixture.db, companionURI) + assert.True(t, exists && active, "D's upvote must be live") + counts := readSubjectCounts(t, fixture.db, `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post) + assert.Equal(t, 1, counts.Upvotes, "D's increment and A's decrement must both apply") + assert.Zero(t, counts.Downvotes) + assert.Equal(t, 1, counts.Score) +} diff --git a/internal/atproto/jetstream/vote_notification_group_retraction_test.go b/internal/atproto/jetstream/vote_notification_group_retraction_test.go new file mode 100644 index 0000000..c15b904 --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_group_retraction_test.go @@ -0,0 +1,227 @@ +//go:build integration + +package jetstream + +import ( + "context" + "testing" + "time" + + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func TestVoteConsumer_UpvoteGroupDeleteThenCreateFlipStaysGone(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + ctx := context.Background() + firstKey := testkit.TID() + firstURI := "at://" + voter + "/social.coves.feed.vote/" + firstKey + first := maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revA, firstKey) + require.NoError(t, consumer.HandleEvent(ctx, first)) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "A's first qualifying upvote must create B's group") + + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + voter, "social.coves.feed.vote", "delete", firstKey, revB, "", first.TimeUS+1_000_000, nil))) + exists, active := voteRowState(t, fixture.db, firstURI) + require.True(t, exists && !active, "A's original vote must be soft-deleted") + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "deleting the last qualifying upvote must remove B's group") + + secondKey := testkit.TID() + require.NotEqual(t, firstKey, secondKey, "A's later upvote must use a new rkey") + secondURI := "at://" + voter + "/social.coves.feed.vote/" + secondKey + second := maintenanceVoteAtKey(voter, fixture.post, time.Now().UTC().Format(time.RFC3339Nano), revC, secondKey) + require.NoError(t, consumer.HandleEvent(ctx, second)) + exists, active = voteRowState(t, fixture.db, secondURI) + require.True(t, exists && active, "A's new-rkey upvote must be indexed and live") + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "A's later upvote on the same subject must not recreate the deleted group") +} + +func TestVoteConsumer_UpvoteGroupDeleteVoteDeleteFirstWaitsOnErasureLock(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + consumer := fixture.consumer() + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + created := maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revA, key) + require.NoError(t, consumer.HandleEvent(context.Background(), created)) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + deleteEvent := revCommitEvent(voter, "social.coves.feed.vote", "delete", key, revB, "", created.TimeUS+1_000_000, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) // registered before the fixture transaction, which may still be open on failure + deleteResults, consumerResults := make(chan error, 1), make(chan error, 1) + deleteStarted, deleteFinished := false, false + consumerStarted, consumerFinished := false, false + voteGroupResultsCleanup(t, ctx, deleteResults, &deleteStarted, &deleteFinished, "Delete(A)") + voteGroupResultsCleanup(t, ctx, consumerResults, &consumerStarted, &consumerFinished, "HandleEvent(A's vote delete)") + transaction, fixtureProcessID := commentErasureLockTransaction(t, ctx, fixture.db, voter) + deleteStarted = true + go func() { deleteResults <- postgres.NewUserRepository(fixture.db).Delete(ctx, voter) }() + deleteProcessID := commentErasureBlockedByFixture(t, ctx, transaction, fixtureProcessID, "DELETE FROM users") + + consumerStarted = true + go func() { consumerResults <- consumer.HandleEvent(ctx, deleteEvent) }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + if _, err := transaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + return false, err + } + var waitingBeforeContent bool + err := transaction.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database AND holder.classid = waiter.classid + AND holder.objid = waiter.objid AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid NOT IN ($1, $2) + AND holder.locktype = 'advisory' AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + AND $1 = ANY(pg_blocking_pids(waiter.pid)) + AND NOT EXISTS (SELECT 1 FROM pg_locks other WHERE other.pid = waiter.pid + AND NOT other.granted AND other.locktype IN ('tuple', 'transactionid')) + AND NOT EXISTS (SELECT 1 FROM pg_locks content WHERE content.pid = waiter.pid + AND content.locktype = 'relation' AND content.relation IN + ('votes'::regclass, 'posts'::regclass, 'comments'::regclass)) + )`, deleteProcessID, fixtureProcessID).Scan(&waitingBeforeContent) + return waitingBeforeContent, err + }, testkit.WithDescription("vote delete consumer waiting for A's erasure advisory lock before any vote/post/comment row lock")) + + require.NoError(t, transaction.Commit()) + commentErasureResult(t, ctx, deleteResults, "Delete(A)") + deleteFinished = true + commentErasureResult(t, ctx, consumerResults, "HandleEvent(A's vote delete)") + consumerFinished = true + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM deleted_accounts WHERE did = $1`, voter)) + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, uri), + "erasure must hard-delete A's original vote") +} + +func TestVoteConsumer_UpvoteGroupDeleteVoteRequestsReadCommittedExplicitly(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + consumer := fixture.consumer() + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + created := maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revA, key) + require.NoError(t, consumer.HandleEvent(context.Background(), created)) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + + ctx := context.Background() + var database string + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT current_database()`).Scan(&database)) + _, err := fixture.db.ExecContext(ctx, + "ALTER DATABASE "+pq.QuoteIdentifier(database)+" SET default_transaction_isolation = 'repeatable read'") + require.NoError(t, err) + fixture.db.SetMaxIdleConns(0) + control, err := fixture.db.BeginTx(ctx, nil) + require.NoError(t, err) + var defaultIsolation string + require.NoError(t, control.QueryRowContext(ctx, `SELECT current_setting('transaction_isolation')`).Scan(&defaultIsolation)) + require.NoError(t, control.Rollback()) + require.Equal(t, "repeatable read", defaultIsolation, + "control: a transaction without options must inherit the overridden default") + + require.NoError(t, consumer.HandleEvent(ctx, revCommitEvent( + voter, "social.coves.feed.vote", "delete", key, revB, "", created.TimeUS+1_000_000, nil))) + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists && !active, "the delete must soft-delete A's live upvote") +} + +func TestVoteConsumer_UpvoteGroupDeleteVoteMaintainsSubjectGroup(t *testing.T) { + t.Parallel() + for _, scenario := range []string{ + "last_post_upvote", "last_comment_upvote", "eight_day_old_upvote", + "pre_activation_upvote", "blocked_upvoter", "missing_post_row", + } { + t.Run(scenario, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + recipient, subject := fixture.author, fixture.post + if scenario == "last_comment_upvote" { + id := testkit.UniqueID(t) + recipient = "did:plc:" + id + "commenter" + insertBridgedUserOnPDS(t, fixture.db, recipient, id+"commenter.test", bridgedTestNativePDS) + commentKey := testkit.TID() + subject = "at://" + recipient + "/" + CommentCollection + "/" + commentKey + _, err := fixture.db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'bafupvoteroot', $4, 'bafupvoteroot', 'comment', NOW())`, + subject, commentKey, recipient, fixture.post) + require.NoError(t, err) + } + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + created := maintenanceVoteAtKey(voter, subject, fixture.createdAt, revA, key) + require.NoError(t, consumer.HandleEvent(context.Background(), created)) + require.Equal(t, 1, groupCount(t, fixture.db, recipient, subject), + "fixture: the first qualifying upvote must create the subject author's group") + + switch scenario { + case "eight_day_old_upvote": + _, err := fixture.db.Exec(`UPDATE votes SET created_at = NOW() - INTERVAL '8 days' WHERE uri = $1`, uri) + require.NoError(t, err) + case "pre_activation_upvote": + _, err := fixture.db.Exec(`UPDATE votes SET created_at = + (SELECT activated_at - INTERVAL '1 second' FROM notification_activation) WHERE uri = $1`, uri) + require.NoError(t, err) + case "blocked_upvoter": + ineligibleUpvoteTime(t, fixture, "recipient_blocks_voter", voter) + case "missing_post_row": + result, err := fixture.db.Exec(`DELETE FROM posts WHERE uri = $1`, subject) + require.NoError(t, err) + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows, "fixture: the subject row must be gone") + require.Equal(t, 1, groupCount(t, fixture.db, recipient, subject), + "fixture: deleting the post must leave its upvote group for maintenance") + } + + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent( + voter, "social.coves.feed.vote", "delete", key, revB, "", created.TimeUS+1_000_000, nil))) + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists && !active, "the vote must be soft-deleted before checking group maintenance") + require.Zero(t, groupCount(t, fixture.db, recipient, subject), + "deleting the last live qualifying upvote must remove the %s group", scenario) + }) + } +} + +func TestVoteConsumer_UpvoteGroupDeleteVotePreservesCompanionAndSort(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + voter := fixture.voter(t) + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + created := maintenanceVoteAtKey(voter, fixture.post, fixture.createdAt, revA, key) + require.NoError(t, consumer.HandleEvent(context.Background(), created)) + otherURI := deliverGroupVote(t, consumer, fixture.voter(t), fixture.post, "up", fixture.createdAt) + groupID, _ := maintenanceGroup(t, fixture) + past := ageMaintenanceGroup(t, fixture.db, groupID) + + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent( + voter, "social.coves.feed.vote", "delete", key, revB, "", created.TimeUS+1_000_000, nil))) + exists, active := voteRowState(t, fixture.db, uri) + require.True(t, exists && !active, "A's vote must be soft-deleted") + exists, active = voteRowState(t, fixture.db, otherURI) + require.True(t, exists && active, "D's qualifying upvote must remain live") + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + fixture.author, fixture.post).Scan(&storedID, &storedSort), "D's vote must keep the group") + require.Equal(t, groupID, storedID, "the surviving group must retain its id") + require.Truef(t, storedSort.Equal(past), "deleting A's vote moved sort_at from %s to %s", past, storedSort) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) +} diff --git a/internal/atproto/jetstream/vote_notification_groups_test.go b/internal/atproto/jetstream/vote_notification_groups_test.go new file mode 100644 index 0000000..83eae0a --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_groups_test.go @@ -0,0 +1,477 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type upvoteGroupFixture struct { + db *sql.DB + author string + community string + post string + createdAt string +} + +func newUpvoteGroupFixture(t *testing.T) upvoteGroupFixture { + t.Helper() + db := testkit.DB(t) + id := testkit.UniqueID(t) + author := "did:plc:" + id + "author" + community := "did:plc:" + id + "community" + key := testkit.TID() + post := pv2URI(author, key) + seedIndexedPost(t, db, post, community, author, key) + return upvoteGroupFixture{db: db, author: author, community: community, post: post, + createdAt: activatedCommentNotificationTime(t, db, context.Background())} +} + +func (fixture upvoteGroupFixture) addPost(t *testing.T) string { + t.Helper() + key := testkit.TID() + uri := pv2URI(fixture.author, key) + _, err := fixture.db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'another post', NOW())`, + uri, key, fixture.author, fixture.community) + require.NoError(t, err) + return uri +} + +func (fixture upvoteGroupFixture) voter(t *testing.T) string { + t.Helper() + id := testkit.UniqueID(t) + did := "did:plc:" + id + "voter" + insertBridgedUserOnPDS(t, fixture.db, did, id+"voter.test", bridgedTestNativePDS) + return did +} + +func (fixture upvoteGroupFixture) consumer(options ...VoteEventConsumerOption) *VoteEventConsumer { + return NewVoteEventConsumer(postgres.NewVoteRepository(fixture.db), newMockUserService(), fixture.db, + append([]VoteEventConsumerOption{WithVoteNotifications(postgres.NewNotificationRepository(fixture.db))}, options...)...) +} + +func upvoteGroupEvent(voter, subject, direction, createdAt, rev string) *JetstreamEvent { + return revCommitEvent(voter, "social.coves.feed.vote", "create", testkit.TID(), rev, + "bafupvotegroupvote", time.Now().UnixMicro(), map[string]interface{}{ + "$type": "social.coves.feed.vote", "subject": map[string]interface{}{"uri": subject, "cid": "bafupvotesubject"}, + "direction": direction, "createdAt": createdAt, + }) +} + +func deliverGroupVote(t *testing.T, consumer *VoteEventConsumer, voter, subject, direction, createdAt string) string { + t.Helper() + event := upvoteGroupEvent(voter, subject, direction, createdAt, testkit.TID()) + require.NoError(t, consumer.HandleEvent(context.Background(), event)) + uri := "at://" + voter + "/social.coves.feed.vote/" + event.Commit.RKey + exists, active := voteRowState(t, consumer.db, uri) + require.True(t, exists && active, "fixture: the vote must be indexed and active") + return uri +} + +func groupCount(t *testing.T, db *sql.DB, recipient, subject string) int { + t.Helper() + return countRows(t, db, `SELECT count(*) FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, recipient, subject) +} + +func TestVoteConsumer_UpvoteGroupsPostCommentAndLegacyAuthor(t *testing.T) { + t.Parallel() + for _, kind := range []string{"postv2", "comment", "legacy"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + recipient, subject, root := fixture.author, fixture.post, fixture.post + switch kind { + case "comment": + // A second indexed user writes the comment under fixture.author's post. + id := testkit.UniqueID(t) + recipient = "did:plc:" + id + "commenter" + insertBridgedUserOnPDS(t, fixture.db, recipient, id+"commenter.test", bridgedTestNativePDS) + key := testkit.TID() + subject = "at://" + recipient + "/" + CommentCollection + "/" + key + _, err := fixture.db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'bafupvoteroot', $4, 'bafupvoteroot', 'comment', NOW())`, + subject, key, recipient, fixture.post) + require.NoError(t, err) + case "legacy": + key := testkit.TID() + subject = "at://" + fixture.community + "/social.coves.community.post/" + key + root = subject + _, err := fixture.db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'legacy post', NOW())`, + subject, key, fixture.author, fixture.community) + require.NoError(t, err) + } + deliverGroupVote(t, fixture.consumer(), fixture.voter(t), subject, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, recipient, subject), + "a qualifying upvote must create exactly one group for the actual author") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2 AND root_post_uri = $3 + AND record_uri IS NULL AND record_cid IS NULL AND actor_did IS NULL`, recipient, subject, root), + "group navigation and nullable record/actor fields must match the subject") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications`)) + if kind == "comment" { + require.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM notifications WHERE recipient_did = $1`, + fixture.author), "the root post's author did not write the comment") + } + if kind == "legacy" { + require.Zero(t, groupCount(t, fixture.db, fixture.community, subject), "the community is not the legacy post author") + } + }) + } +} + +type recordingVoteErasureRepository struct { + notifications.Repository + erasureCalls int +} + +func (repository *recordingVoteErasureRepository) ErasureGateTx(ctx context.Context, tx *sql.Tx, did string) (bool, error) { + repository.erasureCalls++ + return repository.Repository.ErasureGateTx(ctx, tx, did) +} + +func TestVoteConsumer_UpvoteGroupBumpsOnlyForNewVotes(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + first := upvoteGroupEvent(fixture.voter(t), fixture.post, "up", fixture.createdAt, revA) + consumer := fixture.consumer() + require.NoError(t, consumer.HandleEvent(context.Background(), first)) + var groupID int64 + var initialSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications WHERE reason = 'upvote' + AND recipient_did = $1 AND subject_uri = $2`, fixture.author, fixture.post).Scan(&groupID, &initialSort), + "the first vote must create the group before a bump can be measured") + deliverGroupVote(t, consumer, fixture.voter(t), fixture.post, "up", fixture.createdAt) + var bumpedID int64 + var bumpedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications WHERE reason = 'upvote' + AND recipient_did = $1 AND subject_uri = $2`, fixture.author, fixture.post).Scan(&bumpedID, &bumpedSort)) + assert.Equal(t, groupID, bumpedID, "another voter bumps the same group row") + assert.True(t, bumpedSort.After(initialSort), "another qualifying vote must raise sort_at") + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + firstURI := "at://" + first.Did + "/social.coves.feed.vote/" + first.Commit.RKey + recorder := &recordingVoteErasureRepository{Repository: postgres.NewNotificationRepository(fixture.db)} + replayConsumer := fixture.consumer(WithVoteNotifications(recorder)) + for index, rev := range []string{revB, revB, revA} { + past := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Microsecond) + _, err := fixture.db.Exec(`UPDATE notifications SET sort_at = $1 WHERE id = $2`, past, groupID) + require.NoError(t, err) + replay := *first + commit := *first.Commit + commit.Rev = rev + replay.Commit = &commit + beforeCalls := recorder.erasureCalls + require.NoError(t, replayConsumer.HandleEvent(context.Background(), &replay)) + var storedID int64 + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT id, sort_at FROM notifications WHERE id = $1`, groupID).Scan(&storedID, &storedSort)) + assert.Equal(t, groupID, storedID) + assert.True(t, storedSort.Equal(past), "rev %s replay must not bump the group", rev) + assert.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + assert.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, firstURI)) + assert.Equal(t, 2, readSubjectCounts(t, fixture.db, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post).Upvotes) + var storedRev string + require.NoError(t, fixture.db.QueryRow(`SELECT rev FROM jetstream_record_revs WHERE record_uri = $1`, firstURI).Scan(&storedRev)) + assert.Equal(t, revB, storedRev, "the higher rev wins even when the vote insert conflicts") + if index == 0 { + assert.Equal(t, beforeCalls+1, recorder.erasureCalls, + "the higher rev must pass the gate before its vote insert conflicts") + } else { + assert.Equal(t, beforeCalls, recorder.erasureCalls, "rev-gate losses must not call ErasureGateTx") + } + } +} + +func TestVoteConsumer_UpvoteGroupSelfAndDownvoteGuards(t *testing.T) { + t.Parallel() + t.Run("self_upvote_guard", func(t *testing.T) { + fixture := newUpvoteGroupFixture(t) + deliverGroupVote(t, fixture.consumer(), fixture.author, fixture.post, "up", fixture.createdAt) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), "self-upvotes must not notify") + }) + t.Run("downvote_guard", func(t *testing.T) { + fixture := newUpvoteGroupFixture(t) + deliverGroupVote(t, fixture.consumer(), fixture.voter(t), fixture.post, "down", fixture.createdAt) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), "downvotes must not create groups") + }) +} + +func TestVoteConsumer_UpvoteGroupGuardsHaveEligibleSiblingControl(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + other := fixture.addPost(t) + deliverGroupVote(t, fixture.consumer(), fixture.voter(t), other, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, other), + "positive control: the indexed author can receive a sibling-post upvote") +} + +func ineligibleUpvoteTime(t *testing.T, fixture upvoteGroupFixture, gate, voter string) string { + t.Helper() + createdAt := fixture.createdAt + switch gate { + case "recipient_blocks_voter", "voter_blocks_recipient": + blocker, blocked := fixture.author, voter + if gate == "voter_blocks_recipient" { + blocker, blocked = voter, fixture.author + } + _, err := fixture.db.Exec(`INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) + VALUES ($1, $2, $3, 'bafupvoteblock')`, blocker, blocked, + "at://"+blocker+"/"+CovesActorBlockCollection+"/"+testkit.TID()) + require.NoError(t, err) + case "before_activation", "older_than_seven_days": + var now time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT NOW()`).Scan(&now)) + activation := now.Add(-2 * time.Minute) + created := now.Add(-time.Hour) + if gate == "older_than_seven_days" { + activation, created = now.Add(-30*24*time.Hour), now.Add(-8*24*time.Hour) + } + _, err := fixture.db.Exec(`UPDATE notification_activation SET activated_at = $1`, activation) + require.NoError(t, err) + createdAt = created.UTC().Format(time.RFC3339Nano) + } + return createdAt +} + +func TestVoteConsumer_UpvoteGroupIneligibleVotesDoNotCreate(t *testing.T) { + t.Parallel() + for _, gate := range []string{"recipient_blocks_voter", "voter_blocks_recipient", "before_activation", "older_than_seven_days"} { + t.Run(gate, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + createdAt := ineligibleUpvoteTime(t, fixture, gate, voter) + deliverGroupVote(t, fixture.consumer(), voter, fixture.post, "up", createdAt) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "%s must not create a group for the indexed author", gate) + }) + } +} + +func TestVoteConsumer_UpvoteGroupIneligibleVotesDoNotBumpOrCreate(t *testing.T) { + t.Parallel() + for _, gate := range []string{"recipient_blocks_voter", "voter_blocks_recipient", "before_activation", "older_than_seven_days"} { + t.Run(gate, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + consumer := fixture.consumer() + deliverGroupVote(t, consumer, fixture.voter(t), fixture.post, "up", fixture.createdAt) + var groupID int64 + require.NoError(t, fixture.db.QueryRow(`SELECT id FROM notifications WHERE reason = 'upvote' + AND recipient_did = $1 AND subject_uri = $2`, fixture.author, fixture.post).Scan(&groupID), + "a qualifying vote must keep this group alive before testing suppression") + secondPost := fixture.addPost(t) + voter := fixture.voter(t) + createdAt := ineligibleUpvoteTime(t, fixture, gate, voter) + past := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Microsecond) + _, err := fixture.db.Exec(`UPDATE notifications SET sort_at = $1 WHERE id = $2`, past, groupID) + require.NoError(t, err) + deliverGroupVote(t, consumer, voter, fixture.post, "up", createdAt) + var sortAt time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT sort_at FROM notifications WHERE id = $1`, groupID).Scan(&sortAt)) + assert.True(t, sortAt.Equal(past), "%s must not bump the existing group", gate) + deliverGroupVote(t, consumer, voter, secondPost, "up", createdAt) + assert.Zero(t, groupCount(t, fixture.db, fixture.author, secondPost), "%s must not create a group", gate) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + }) + } +} + +func TestVoteConsumer_UpvoteGroupRecipientEligibility(t *testing.T) { + t.Parallel() + for _, gate := range []string{"aggregator", "erased", "unindexed", "trusted_bridge"} { + t.Run(gate, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + control := "did:plc:" + testkit.UniqueID(t) + "control" + insertBridgedUserOnPDS(t, fixture.db, control, testkit.UniqueID(t)+"control.test", bridgedTestNativePDS) + controlKey := testkit.TID() + controlPost := pv2URI(control, controlKey) + _, err := fixture.db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'control', NOW())`, + controlPost, controlKey, control, fixture.community) + require.NoError(t, err) + options := []VoteEventConsumerOption{} + switch gate { + case "aggregator": + _, err = fixture.db.Exec(`INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, 'Aggregator recipient', $2, 'bafupvoteservice')`, fixture.author, + "at://"+fixture.author+"/social.coves.aggregator.service/self") + case "erased": + _, err = fixture.db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, fixture.author) + case "unindexed": + _, err = fixture.db.Exec(`DELETE FROM users WHERE did = $1`, fixture.author) + case "trusted_bridge": + insertBridgedUserOnPDS(t, fixture.db, fixture.author, testkit.UniqueID(t)+"author.test", bridgedTestPDS) + options = append(options, WithVoteBridgeTrust(NewBridgeTrust([]string{bridgedTestPDS}))) + } + require.NoError(t, err) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM posts WHERE uri = $1`, fixture.post), + "fixture: recipient's post must still be countable") + t.Run("suppressed", func(t *testing.T) { + deliverGroupVote(t, fixture.consumer(options...), fixture.voter(t), fixture.post, "up", fixture.createdAt) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "%s recipient must not receive a group", gate) + }) + t.Run("eligible_control", func(t *testing.T) { + deliverGroupVote(t, fixture.consumer(options...), fixture.voter(t), controlPost, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, control, controlPost), + "eligible control recipient must receive a group under the same gate") + }) + if gate == "trusted_bridge" { + t.Run("without_trust", func(t *testing.T) { + deliverGroupVote(t, fixture.consumer(), fixture.voter(t), fixture.post, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "without bridge trust the same bridge-hosted recipient receives a group") + }) + } + }) + } +} + +// Q4: an aggregator or erased voter gives no group, while the vote itself is +// still indexed and counted. An eligible voter on the same post is the control. +func TestVoteConsumer_UpvoteGroupVoterEligibility(t *testing.T) { + t.Parallel() + for _, gate := range []string{"aggregator", "erased"} { + t.Run(gate, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + var err error + switch gate { + case "aggregator": + _, err = fixture.db.Exec(`INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, 'Aggregator voter', $2, 'bafupvoteservice')`, voter, + "at://"+voter+"/social.coves.aggregator.service/self") + case "erased": + _, err = fixture.db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, voter) + } + require.NoError(t, err) + consumer := fixture.consumer() + t.Run("suppressed", func(t *testing.T) { + deliverGroupVote(t, consumer, voter, fixture.post, "up", fixture.createdAt) + require.Equal(t, 1, readSubjectCounts(t, fixture.db, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post).Upvotes, + "the %s voter's vote must still be counted", gate) + require.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post), + "an %s voter must not create a group", gate) + }) + t.Run("eligible_control", func(t *testing.T) { + deliverGroupVote(t, consumer, fixture.voter(t), fixture.post, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "an eligible voter on the same post must create the group") + }) + }) + } +} + +// Q4: the bridge rule applies to the recipient only, so a voter hosted on a +// trusted bridge PDS still bumps a native recipient's group. +func TestVoteConsumer_UpvoteGroupBridgeHostedVoterBumps(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + // seedIndexedPost puts the author on the bridge host; this recipient is native. + insertBridgedUserOnPDS(t, fixture.db, fixture.author, testkit.UniqueID(t)+"author.test", bridgedTestNativePDS) + id := testkit.UniqueID(t) + voter := "did:plc:" + id + "bridgevoter" + insertBridgedUserOnPDS(t, fixture.db, voter, id+"bridgevoter.test", bridgedTestPDS) + trust := NewBridgeTrust([]string{bridgedTestPDS}) + require.True(t, trust.TrustsPDS(bridgedTestPDS), "fixture: the consumer must trust the voter's PDS host") + deliverGroupVote(t, fixture.consumer(WithVoteBridgeTrust(trust)), voter, fixture.post, "up", fixture.createdAt) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post), + "a voter on a trusted bridge PDS must still bump the native recipient's group") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM notifications`)) +} + +type failingUpvoteGroupRepository struct { + notifications.Repository + failure error + intents []notifications.UpvoteGroupIntent +} + +func (repository *failingUpvoteGroupRepository) ApplyUpvoteGroupTx(_ context.Context, _ *sql.Tx, intent notifications.UpvoteGroupIntent) error { + repository.intents = append(repository.intents, intent) + return repository.failure +} + +func TestVoteConsumer_UpvoteGroupFailureRollsBackVoteCountAndRev(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + event := upvoteGroupEvent(voter, fixture.post, "up", fixture.createdAt, testkit.TID()) + voteURI := "at://" + voter + "/social.coves.feed.vote/" + event.Commit.RKey + injected := errors.New("injected upvote group write failure") + repository := &failingUpvoteGroupRepository{Repository: postgres.NewNotificationRepository(fixture.db), failure: injected} + consumer := fixture.consumer(WithVoteNotifications(repository)) + err := consumer.HandleEvent(context.Background(), event) + require.Equal(t, []notifications.UpvoteGroupIntent{{Action: notifications.UpvoteGroupBump, + RecipientDID: fixture.author, SubjectURI: fixture.post, RootPostURI: fixture.post}}, repository.intents, + "fan-out must request precisely the author's post group") + require.ErrorIs(t, err, injected, "notification write failure must propagate") + assert.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, voteURI), "failed vote insert must roll back") + assert.Zero(t, readSubjectCounts(t, fixture.db, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post).Upvotes, + "failed vote count must roll back") + assert.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs WHERE record_uri = $1`, voteURI), + "failed rev claim must roll back") + assert.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post)) + require.NoError(t, fixture.consumer().HandleEvent(context.Background(), event), "identical retry must succeed") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, voteURI)) + require.Equal(t, 1, readSubjectCounts(t, fixture.db, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post).Upvotes) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, voteURI, event.Commit.Rev)) +} + +func TestVoteConsumer_UpvoteGroupMissingActivationRollsBackVoteCountAndRev(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + voter := fixture.voter(t) + var activatedAt time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT activated_at FROM notification_activation`).Scan(&activatedAt), + "fixture: the activation row must exist before it is removed") + result, err := fixture.db.Exec(`DELETE FROM notification_activation`) + require.NoError(t, err) + removed, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, removed, "fixture: the singleton activation row must be removed") + event := upvoteGroupEvent(voter, fixture.post, "up", fixture.createdAt, testkit.TID()) + voteURI := "at://" + voter + "/social.coves.feed.vote/" + event.Commit.RKey + consumer := fixture.consumer() + err = consumer.HandleEvent(context.Background(), event) + require.ErrorIs(t, err, postgres.ErrNotificationActivationMissing, "a missing activation row must fail the vote's transaction") + assert.ErrorContains(t, err, "compute vote notifications") + assert.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, voteURI), "failed vote insert must roll back") + assert.Zero(t, readSubjectCounts(t, fixture.db, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post).Upvotes, + "failed vote count must roll back") + assert.Zero(t, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs WHERE record_uri = $1`, voteURI), + "failed rev claim must roll back") + assert.Zero(t, groupCount(t, fixture.db, fixture.author, fixture.post)) + _, err = fixture.db.Exec(`INSERT INTO notification_activation (activated_at) VALUES ($1)`, activatedAt) + require.NoError(t, err, "fixture: restore the activation row") + require.NoError(t, consumer.HandleEvent(context.Background(), event), "identical retry must succeed once the row is restored") + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM votes WHERE uri = $1`, voteURI)) + require.Equal(t, 1, readSubjectCounts(t, fixture.db, + `SELECT upvote_count, downvote_count, score FROM posts WHERE uri = $1`, fixture.post).Upvotes) + require.Equal(t, 1, groupCount(t, fixture.db, fixture.author, fixture.post)) + require.Equal(t, 1, countRows(t, fixture.db, `SELECT count(*) FROM jetstream_record_revs + WHERE record_uri = $1 AND rev = $2`, voteURI, event.Commit.Rev)) +} diff --git a/internal/atproto/jetstream/vote_notification_upvoter_order_test.go b/internal/atproto/jetstream/vote_notification_upvoter_order_test.go new file mode 100644 index 0000000..1476e13 --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_upvoter_order_test.go @@ -0,0 +1,72 @@ +//go:build integration + +package jetstream + +import ( + "context" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/db/postgres" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestVoteConsumer_RecentUpvotersUseIndexTimeNotRecordTime(t *testing.T) { + fixture := newUpvoteGroupFixture(t) + ctx := context.Background() + var now time.Time + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT now()`).Scan(&now)) + now = now.UTC().Truncate(time.Microsecond) + // The vote fixture indexes its post directly without an acceptance; the + // notification list requires a publicly accepted subject. + acceptanceKey := testkit.TID() + _, err := fixture.db.ExecContext(ctx, `INSERT INTO community_post_admissions + (community_did, post_uri, status, acceptance_uri, acceptance_rkey, + accepted_cid, evaluated_cid, last_community_rev, last_community_op_rank, created_at, updated_at) + VALUES ($1, $2, 'accepted', $3, $4, 'bafredrivesubject', 'bafredrivesubject', + '3lqqqqqqqqqq2', $5, $6, $6)`, fixture.community, fixture.post, + "at://"+fixture.community+"/social.coves.community.acceptance/"+acceptanceKey, + acceptanceKey, int16(posts.CommunityOpPut), now) + require.NoError(t, err) + // The shared fixture activates about two minutes ago; V3's hour-old + // createdAt must be inside the activation and seven-day windows. + _, err = fixture.db.ExecContext(ctx, `UPDATE notification_activation SET activated_at = $1`, now.Add(-2*time.Hour)) + require.NoError(t, err) + v1, v2, v3 := fixture.voter(t), fixture.voter(t), fixture.voter(t) + for _, vote := range []struct { + voter string + index time.Time + }{ + {v1, now.Add(-2 * time.Minute)}, + {v2, now.Add(-time.Minute)}, + } { + key := testkit.TID() + _, err := fixture.db.ExecContext(ctx, `INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at) + VALUES ($1, 'bafupvotegroupvote', $2, $3, $4, 'bafupvotesubject', 'up', $5, $6)`, + "at://"+vote.voter+"/social.coves.feed.vote/"+key, key, vote.voter, fixture.post, + now.Add(-10*time.Second), vote.index) + require.NoError(t, err) + } + before := now.Add(-5 * time.Minute) + _, err = fixture.db.ExecContext(ctx, `INSERT INTO notifications + (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $2, $3)`, fixture.author, fixture.post, before) + require.NoError(t, err) + event := upvoteGroupEvent(v3, fixture.post, "up", now.Add(-time.Hour).Format(time.RFC3339Nano), testkit.TID()) + require.NoError(t, fixture.consumer().HandleEvent(ctx, event)) + var bumped time.Time + require.NoError(t, fixture.db.QueryRowContext(ctx, `SELECT sort_at FROM notifications + WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, fixture.author, fixture.post).Scan(&bumped)) + require.True(t, bumped.After(before), "V3's qualifying vote must bump the existing group") + page, err := postgres.NewNotificationRepository(fixture.db).(notifications.ReadRepository).List(ctx, fixture.author, "", 10) + require.NoError(t, err) + require.Len(t, page.Notifications, 1) + require.Equal(t, fixture.post, page.Notifications[0].SubjectURI) + require.Equal(t, 3, page.Notifications[0].UpvoteCount) + require.Equal(t, []string{v3, v2, v1}, page.Notifications[0].RecentUpvoterDIDs) +} diff --git a/internal/atproto/jetstream/vote_notification_withdrawn_gate_test.go b/internal/atproto/jetstream/vote_notification_withdrawn_gate_test.go new file mode 100644 index 0000000..fbb8625 --- /dev/null +++ b/internal/atproto/jetstream/vote_notification_withdrawn_gate_test.go @@ -0,0 +1,104 @@ +//go:build integration + +package jetstream + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestVoteConsumer_RemovedPostCannotCreateOrBumpGroup(t *testing.T) { + t.Parallel() + for _, kind := range []string{"new group", "existing group", "downvote replaced by upvote"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + f := newUpvoteGroupFixture(t) + consumer := f.consumer() + var groupID int64 + var originalSort time.Time + if kind != "new group" { + deliverGroupVote(t, consumer, f.voter(t), f.post, "up", f.createdAt) + groupID, _ = maintenanceGroup(t, f) + ageMaintenanceGroup(t, f.db, groupID) + require.NoError(t, f.db.QueryRow(`SELECT sort_at FROM notifications WHERE id = $1`, groupID).Scan(&originalSort)) + } + voter := f.voter(t) + var oldVote string + if kind == "downvote replaced by upvote" { + oldVote = deliverGroupVote(t, consumer, voter, f.post, "down", f.createdAt) + require.Equal(t, 1, countRows(t, f.db, `SELECT count(*) FROM votes WHERE uri = $1 AND direction = 'down' AND deleted_at IS NULL`, oldVote)) + } + removeNotificationReferencePost(t, f.db, f.post) + if oldVote == "" { + deliverGroupVote(t, consumer, voter, f.post, "up", f.createdAt) + } else { + replaceMaintenanceVote(t, f, consumer, voter, "up", oldVote) + } + if kind == "new group" { + require.Zero(t, groupCount(t, f.db, f.author, f.post), "removed post must not start a group") + return + } + var storedID int64 + var storedSort time.Time + require.NoError(t, f.db.QueryRow(`SELECT id, sort_at FROM notifications WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + f.author, f.post).Scan(&storedID, &storedSort)) + require.Equal(t, groupID, storedID) + require.Truef(t, storedSort.Equal(originalSort), "removed post group sort_at changed from %s to %s", originalSort, storedSort) + }) + } +} + +func TestVoteConsumer_WithdrawnCommentRootCannotCreateGroup(t *testing.T) { + t.Parallel() + for _, kind := range []string{"deleted root", "removed root"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + f := newUpvoteGroupFixture(t) + commenter := f.voter(t) + key := testkit.TID() + commentURI := "at://" + commenter + "/" + CommentCollection + "/" + key + _, err := f.db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, 'bafupvotesubject', $2, $3, $4, 'bafupvoteroot', $4, 'bafupvoteroot', 'comment', NOW())`, + commentURI, key, commenter, f.post) + require.NoError(t, err) + if kind == "removed root" { + removeNotificationReferencePost(t, f.db, f.post) + } else { + _, err = f.db.Exec(`UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, f.post) + require.NoError(t, err) + } + deliverGroupVote(t, f.consumer(), f.voter(t), commentURI, "up", f.createdAt) + require.Zero(t, groupCount(t, f.db, commenter, commentURI), "withdrawn root must suppress the comment upvote group") + }) + } +} + +func TestVoteConsumer_RetractionStillDeletesLastGroupAfterRemoval(t *testing.T) { + t.Parallel() + f := newUpvoteGroupFixture(t) + consumer := f.consumer() + voter := f.voter(t) + key := testkit.TID() + created := maintenanceVoteAtKey(voter, f.post, f.createdAt, revA, key) + require.NoError(t, consumer.HandleEvent(context.Background(), created)) + var groupID int64 + var sortAt time.Time + require.NoError(t, f.db.QueryRow(`SELECT id, sort_at FROM notifications WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + f.author, f.post).Scan(&groupID, &sortAt)) + require.NotZero(t, groupID) + removeNotificationReferencePost(t, f.db, f.post) + require.NoError(t, consumer.HandleEvent(context.Background(), revCommitEvent(voter, "social.coves.feed.vote", "delete", key, + revB, "", created.TimeUS+1_000_000, nil))) + var deletedAt sql.NullTime + require.NoError(t, f.db.QueryRow(`SELECT deleted_at FROM votes WHERE uri = $1`, + "at://"+voter+"/social.coves.feed.vote/"+key).Scan(&deletedAt)) + require.True(t, deletedAt.Valid) + require.Zero(t, groupCount(t, f.db, f.author, f.post), "retracting the last upvote deletes the old group even after removal") +} diff --git a/internal/atproto/lexicon/social/coves/notification/defs.json b/internal/atproto/lexicon/social/coves/notification/defs.json new file mode 100644 index 0000000..6396319 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/notification/defs.json @@ -0,0 +1,121 @@ +{ + "lexicon": 1, + "id": "social.coves.notification.defs", + "defs": { + "reason": { + "type": "string", + "description": "The kind of activity that produced the notification. Other reasons may be added later.", + "knownValues": [ + "postReply", + "commentReply", + "mention", + "upvote" + ] + }, + "notificationView": { + "type": "object", + "description": "One notification in the authenticated account's list.", + "required": ["reason", "sortAt", "isRead", "rootPost"], + "properties": { + "reason": { + "type": "ref", + "ref": "#reason" + }, + "sortAt": { + "type": "string", + "format": "datetime" + }, + "isRead": { + "type": "boolean" + }, + "rootPost": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": {"type": "string", "format": "at-uri"}, + "cid": {"type": "string", "format": "cid"}, + "status": {"type": "string", "description": "Read-time status when the root post is unavailable: deleted by its author, removed by its community's moderators, or removed by the server admin.", "knownValues": ["deleted", "removedByModerator", "removedByServerAdmin"]}, + "title": {"type": "string", "maxGraphemes": 300, "maxLength": 3000}, + "labels": {"type": "ref", "ref": "com.atproto.label.defs#selfLabels", "description": "Self-labels the referenced record's author applied; absent when none or when the reference has a status."}, + "thumbnail": {"type": "string", "format": "uri", "description": "Preview image URL for a post (link-card thumbnail or first image), served through the AppView's image proxy; never set for comments or references with a status."}, + "thumbnailAlt": {"type": "string", "maxLength": 10000, "maxGraphemes": 1000, "description": "Alt text of the image in thumbnail, from the post author's first image; set only alongside an image post's thumbnail, never for link-card thumbnails."}, + "community": { + "type": "ref", + "ref": "social.coves.community.post.defs#communityRef" + } + } + }, + "subject": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": {"type": "string", "format": "at-uri"}, + "cid": {"type": "string", "format": "cid"}, + "status": {"type": "string", "description": "Read-time status when the subject is unavailable: deleted by its author, removed by its community's moderators, or removed by the server admin.", "knownValues": ["deleted", "removedByModerator", "removedByServerAdmin"]}, + "preview": {"type": "string", "description": "The post's title, or the first 140 graphemes of the post body or comment text when there is no title; absent when the reference has a status."}, + "labels": {"type": "ref", "ref": "com.atproto.label.defs#selfLabels", "description": "Self-labels the referenced record's author applied; absent when none or when the reference has a status."}, + "thumbnail": {"type": "string", "format": "uri", "description": "Preview image URL for a post (link-card thumbnail or first image), served through the AppView's image proxy; never set for comments or references with a status."}, + "thumbnailAlt": {"type": "string", "maxLength": 10000, "maxGraphemes": 1000, "description": "Alt text of the image in thumbnail, from the post author's first image; set only alongside an image post's thumbnail, never for link-card thumbnails."} + } + }, + "record": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": {"type": "string", "format": "at-uri"}, + "cid": {"type": "string", "format": "cid"}, + "status": {"type": "string", "description": "Read-time status when the record is unavailable: deleted by its author, removed by its community's moderators, or removed by the server admin.", "knownValues": ["deleted", "removedByModerator", "removedByServerAdmin"]}, + "excerpt": {"type": "string", "maxGraphemes": 140, "description": "The first 140 graphemes of the triggering record's text; absent when the record has a status."}, + "createdAt": {"type": "string", "format": "datetime"}, + "labels": {"type": "ref", "ref": "com.atproto.label.defs#selfLabels", "description": "Self-labels the referenced record's author applied; absent when none or when the reference has a status."}, + "thumbnail": {"type": "string", "format": "uri", "description": "Preview image URL for a post (link-card thumbnail or first image), served through the AppView's image proxy; never set for comments or references with a status."}, + "thumbnailAlt": {"type": "string", "maxLength": 10000, "maxGraphemes": 1000, "description": "Alt text of the image in thumbnail, from the post author's first image; set only alongside an image post's thumbnail, never for link-card thumbnails."} + } + }, + "author": { + "type": "ref", + "ref": "social.coves.actor.defs#profileView" + }, + "upvoteCount": { + "type": "integer", + "minimum": 0, + "description": "Qualifying upvotes from accounts on this network plus the item's bridged upvote total from federated platforms" + }, + "recentUpvoters": { + "type": "array", + "maxLength": 3, + "items": {"type": "ref", "ref": "social.coves.actor.defs#profileView"}, + "description": "Up to 3 most recent qualifying voters on this network, newest first; absent when there are none, for example when every upvote is bridged. Newest is by when this AppView indexed the vote, not the vote record's createdAt." + } + } + }, + "preferences": { + "type": "object", + "description": "Notification reasons enabled for the authenticated account.", + "required": [ + "postReply", + "commentReply", + "mention", + "upvote" + ], + "properties": { + "postReply": { + "type": "boolean", + "description": "Receive replies to your posts." + }, + "commentReply": { + "type": "boolean", + "description": "Receive replies to your comments." + }, + "mention": { + "type": "boolean", + "description": "Receive mentions in posts and comments." + }, + "upvote": { + "type": "boolean", + "description": "Receive upvote group notifications." + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/notification/getPreferences.json b/internal/atproto/lexicon/social/coves/notification/getPreferences.json new file mode 100644 index 0000000..93ed109 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/notification/getPreferences.json @@ -0,0 +1,17 @@ +{ + "lexicon": 1, + "id": "social.coves.notification.getPreferences", + "defs": { + "main": { + "type": "query", + "description": "Get notification preferences for the authenticated account.", + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "social.coves.notification.defs#preferences" + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/notification/getUnreadCount.json b/internal/atproto/lexicon/social/coves/notification/getUnreadCount.json new file mode 100644 index 0000000..670da98 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/notification/getUnreadCount.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "social.coves.notification.getUnreadCount", + "defs": { + "main": { + "type": "query", + "description": "Get the number of unread notifications for the authenticated account, capped at 101 (101 means more than 100).", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 101, + "description": "Number of visible unread notifications, capped at 101 (101 means more than 100)." + } + } + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/notification/listNotifications.json b/internal/atproto/lexicon/social/coves/notification/listNotifications.json new file mode 100644 index 0000000..c55417c --- /dev/null +++ b/internal/atproto/lexicon/social/coves/notification/listNotifications.json @@ -0,0 +1,55 @@ +{ + "lexicon": 1, + "id": "social.coves.notification.listNotifications", + "defs": { + "main": { + "type": "query", + "description": "List the authenticated account's notifications (replies, mentions and upvote groups), newest first. A deleted or removed reference keeps its row as a placeholder with a status and no content.", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { + "type": "string", + "maxLength": 500 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["notifications"], + "properties": { + "notifications": { + "type": "array", + "items": { + "type": "ref", + "ref": "social.coves.notification.defs#notificationView" + } + }, + "cursor": { + "type": "string", + "maxLength": 500 + }, + "seenAt": { + "type": "string", + "format": "datetime" + } + } + } + }, + "errors": [ + { + "name": "InvalidCursor", + "description": "The pagination cursor is invalid." + } + ] + } + } +} diff --git a/internal/atproto/lexicon/social/coves/notification/putPreferences.json b/internal/atproto/lexicon/social/coves/notification/putPreferences.json new file mode 100644 index 0000000..b4b52e7 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/notification/putPreferences.json @@ -0,0 +1,47 @@ +{ + "lexicon": 1, + "id": "social.coves.notification.putPreferences", + "defs": { + "main": { + "type": "procedure", + "description": "Update the authenticated account's notification preferences.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "postReply": { + "type": "boolean", + "description": "Enable or disable replies to your posts." + }, + "commentReply": { + "type": "boolean", + "description": "Enable or disable replies to your comments." + }, + "mention": { + "type": "boolean", + "description": "Enable or disable mentions." + }, + "upvote": { + "type": "boolean", + "description": "Enable or disable upvote group notifications." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "social.coves.notification.defs#preferences" + } + }, + "errors": [ + { + "name": "AccountNotIndexed", + "description": "The authenticated account has not been indexed by this AppView yet, so its preferences cannot be stored." + } + ] + } + } +} diff --git a/internal/atproto/lexicon/social/coves/notification/updateSeen.json b/internal/atproto/lexicon/social/coves/notification/updateSeen.json new file mode 100644 index 0000000..8c7b416 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/notification/updateSeen.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "social.coves.notification.updateSeen", + "defs": { + "main": { + "type": "procedure", + "description": "Advance the authenticated account's notification seen time.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "seenAt" + ], + "properties": { + "seenAt": { + "type": "string", + "format": "datetime", + "description": "The largest sortAt the client displayed, echoed verbatim. Future times are clamped to server time." + } + } + } + }, + "errors": [ + { + "name": "AccountNotIndexed", + "description": "The authenticated account has not been indexed by this AppView yet, so its seen time cannot be stored." + } + ] + } + } +} diff --git a/internal/core/bridgedvotes/poller.go b/internal/core/bridgedvotes/poller.go index 5e7fcd3..fee0071 100644 --- a/internal/core/bridgedvotes/poller.go +++ b/internal/core/bridgedvotes/poller.go @@ -351,3 +351,8 @@ func joinSweepErrors(errs ...error) error { } return errors.Join(cancellation...) } + +// Store returns the store used by this poller. +func (p *Poller) Store() Store { + return p.store +} diff --git a/internal/core/bridgedvotes/poller_integration_test.go b/internal/core/bridgedvotes/poller_integration_test.go index c2b0352..b53a4ff 100644 --- a/internal/core/bridgedvotes/poller_integration_test.go +++ b/internal/core/bridgedvotes/poller_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -15,7 +16,9 @@ import ( "testing" "time" + "Coves/internal/atproto/jetstream" "Coves/internal/core/bridgedvotes" + "Coves/internal/core/notifications" "Coves/internal/db/postgres" "Coves/tests/testkit" @@ -262,3 +265,212 @@ func requireSweep(t *testing.T, ctx context.Context, poller *bridgedvotes.Poller _, err := poller.Sweep(ctx) require.NoError(t, err) } + +type notificationPollerFixture struct { + db *sql.DB + ctx context.Context + now time.Time + recipient string +} + +func newNotificationPollerFixture(t *testing.T) notificationPollerFixture { + t.Helper() + f := notificationPollerFixture{db: testkit.DB(t), ctx: context.Background()} + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT now()`).Scan(&f.now)) + f.now = f.now.UTC().Truncate(time.Microsecond) + f.recipient = "did:plc:" + testkit.UniqueID(t) + "pollrecipient" + _, err := f.db.ExecContext(f.ctx, `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, $4)`, + f.recipient, testkit.UniqueID(t)+".test", testkit.Endpoints().PDS.BaseURL, f.now) + require.NoError(t, err) + return f +} + +func (f notificationPollerFixture) post(t *testing.T, pdsURL string) string { + t.Helper() + id := testkit.UniqueID(t) + community := "did:plc:" + id + "pollcommunity" + postURI := "at://" + f.recipient + "/social.coves.community.postv2/" + id + _, err := f.db.ExecContext(f.ctx, `INSERT INTO communities + (did, handle, name, owner_did, created_by_did, hosted_by_did, pds_url, federated_from, created_at) + VALUES ($1, $2, 'poll community', $1, $1, $1, $3, 'lemmy', $4)`, + community, "!"+id+"@local.test", pdsURL, f.now) + require.NoError(t, err) + _, err = f.db.ExecContext(f.ctx, `INSERT INTO posts + (uri, cid, rkey, author_did, community_did, title, created_at, upvote_count, downvote_count, score) + VALUES ($1, $2, $3, $4, $5, 'poll post', $6, 0, 0, 0)`, + postURI, "bafy"+id, id, f.recipient, community, f.now.Add(-time.Hour)) + require.NoError(t, err) + _, err = f.db.ExecContext(f.ctx, `INSERT INTO community_post_admissions + (community_did, post_uri, status, acceptance_uri, acceptance_rkey, accepted_cid, evaluated_cid, created_at, updated_at) + VALUES ($1, $2, 'accepted', $3, $4, $5, $5, $6, $6)`, + community, postURI, "at://"+community+"/social.coves.community.acceptance/"+id, + id, "bafy"+id, f.now) + require.NoError(t, err) + return postURI +} + +func (f notificationPollerFixture) poller(t *testing.T, hosts []string, client *http.Client, repo notifications.Repository) *bridgedvotes.Poller { + t.Helper() + store := postgres.NewBridgedVotesRepository(f.db, + postgres.WithBridgedVoteNotifications(repo, jetstream.NewBridgeTrust(hosts))) + poller, err := bridgedvotes.NewPoller(store, bridgedvotes.NewClient(client), hosts, + bridgedvotes.Options{Lookback: 2 * time.Hour, SweepCap: 100}) + require.NoError(t, err) + return poller +} + +func (f notificationPollerFixture) notificationRepo() notifications.Repository { + return postgres.NewNotificationRepository(f.db, postgres.WithBridgedUpvoteTotals()) +} + +func (f notificationPollerFixture) groupSort(t *testing.T, postURI string) time.Time { + t.Helper() + var sortAt time.Time + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, f.recipient, postURI).Scan(&sortAt)) + return sortAt.UTC().Truncate(time.Microsecond) +} + +func (f notificationPollerFixture) groupCount(t *testing.T, postURI string) int { + t.Helper() + var count int + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT count(*) FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, f.recipient, postURI).Scan(&count)) + return count +} + +func (f notificationPollerFixture) bridgedTotal(t *testing.T, postURI string) int { + t.Helper() + var total int + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT bridged_upvote_count FROM posts WHERE uri = $1`, postURI).Scan(&total)) + return total +} + +func (f notificationPollerFixture) watermark(t *testing.T, postURI string) sql.NullTime { + t.Helper() + var at sql.NullTime + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT bridged_polled_at FROM posts WHERE uri = $1`, postURI).Scan(&at)) + return at +} + +func (f notificationPollerFixture) unread(t *testing.T, repo notifications.Repository, want int) { + t.Helper() + got, err := repo.(notifications.ReadRepository).CountUnread(f.ctx, f.recipient) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestPollerSweepTrustedNativeIncreaseCreatesUpvoteGroup(t *testing.T) { + t.Parallel() + f := newNotificationPollerFixture(t) + bridge := &aggregateServer{aggregates: map[string]servedAggregate{}} + server := httptest.NewServer(bridge) + t.Cleanup(server.Close) + postURI := f.post(t, server.URL) + bridge.replace(servedAggregate{URI: postURI, Upvotes: 3, UpdatedAt: f.now.Format(time.RFC3339Nano)}) + repo := f.notificationRepo() + requireSweep(t, f.ctx, f.poller(t, []string{server.URL}, server.Client(), repo)) + require.Contains(t, bridge.requestedURIs(), postURI) + require.Equal(t, 3, f.bridgedTotal(t, postURI)) + require.Equal(t, 1, f.groupCount(t, postURI)) +} + +func TestPollerSweepDoesNotRequestUntrustedCommunity(t *testing.T) { + t.Parallel() + f := newNotificationPollerFixture(t) + trusted := &aggregateServer{aggregates: map[string]servedAggregate{}} + trustedServer := httptest.NewServer(trusted) + t.Cleanup(trustedServer.Close) + untrusted := &aggregateServer{aggregates: map[string]servedAggregate{}} + untrustedServer := httptest.NewServer(untrusted) + t.Cleanup(untrustedServer.Close) + postURI := f.post(t, untrustedServer.URL) + untrusted.replace(servedAggregate{URI: postURI, Upvotes: 4, UpdatedAt: f.now.Format(time.RFC3339Nano)}) + requireSweep(t, f.ctx, f.poller(t, []string{trustedServer.URL}, trustedServer.Client(), f.notificationRepo())) + require.NotContains(t, untrusted.requestedURIs(), postURI) + require.Equal(t, 0, f.groupCount(t, postURI)) +} + +func TestPollerSweepHostRemovalPreservesStoredGroupsAndTotals(t *testing.T) { + t.Parallel() + f := newNotificationPollerFixture(t) + first := &aggregateServer{aggregates: map[string]servedAggregate{}} + firstServer := httptest.NewServer(first) + t.Cleanup(firstServer.Close) + second := &aggregateServer{aggregates: map[string]servedAggregate{}} + secondServer := httptest.NewServer(second) + t.Cleanup(secondServer.Close) + firstPost := f.post(t, firstServer.URL) + secondPost := f.post(t, secondServer.URL) + first.replace(servedAggregate{URI: firstPost, Upvotes: 3, UpdatedAt: f.now.Add(-time.Minute).Format(time.RFC3339Nano)}) + second.replace(servedAggregate{URI: secondPost, Upvotes: 5, UpdatedAt: f.now.Add(-time.Minute).Format(time.RFC3339Nano)}) + initialRepo := f.notificationRepo() + require.NoError(t, initialRepo.(notifications.ReadRepository).UpdateSeen(f.ctx, f.recipient, f.now.Add(-time.Hour))) + requireSweep(t, f.ctx, f.poller(t, []string{firstServer.URL, secondServer.URL}, firstServer.Client(), initialRepo)) + require.Equal(t, 3, f.bridgedTotal(t, firstPost)) + require.Equal(t, 5, f.bridgedTotal(t, secondPost)) + require.Equal(t, 1, f.groupCount(t, firstPost)) + require.Equal(t, 1, f.groupCount(t, secondPost)) + f.unread(t, initialRepo, 2) + firstSort := f.groupSort(t, firstPost) + secondSort := f.groupSort(t, secondPost) + + firstRequests := len(first.requestedURIs()) + secondRequests := len(second.requestedURIs()) + first.replace(servedAggregate{URI: firstPost, Upvotes: 9, UpdatedAt: f.now.Format(time.RFC3339Nano)}) + second.replace(servedAggregate{URI: secondPost, Upvotes: 6, UpdatedAt: f.now.Format(time.RFC3339Nano)}) + restartedRepo := f.notificationRepo() + requireSweep(t, f.ctx, f.poller(t, []string{secondServer.URL}, secondServer.Client(), restartedRepo)) + require.Len(t, first.requestedURIs(), firstRequests) + require.Greater(t, len(second.requestedURIs()), secondRequests) + require.Equal(t, 3, f.bridgedTotal(t, firstPost)) + require.True(t, f.groupSort(t, firstPost).Equal(firstSort)) + require.Equal(t, 6, f.bridgedTotal(t, secondPost)) + require.True(t, f.groupSort(t, secondPost).After(secondSort)) + page, err := restartedRepo.(notifications.ReadRepository).List(f.ctx, f.recipient, "", 10) + require.NoError(t, err) + require.Len(t, page.Notifications, 2) + listed := make(map[string]notifications.ListedNotification) + for _, notification := range page.Notifications { + listed[notification.SubjectURI] = notification + } + require.Equal(t, 3, listed[firstPost].UpvoteCount) + require.False(t, listed[firstPost].IsRead) + require.Equal(t, 6, listed[secondPost].UpvoteCount) + require.False(t, listed[secondPost].IsRead) + f.unread(t, restartedRepo, 2) +} + +type pollerFailingNotificationRepo struct { + notifications.Repository + err error +} + +func (r pollerFailingNotificationRepo) ApplyUpvoteGroupTx(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + if err := r.Repository.ApplyUpvoteGroupTx(ctx, tx, intent); err != nil { + return err + } + return r.err +} + +func TestPollerSweepNotificationFailureLeavesBatchUnpolled(t *testing.T) { + t.Parallel() + f := newNotificationPollerFixture(t) + bridge := &aggregateServer{aggregates: map[string]servedAggregate{}} + server := httptest.NewServer(bridge) + t.Cleanup(server.Close) + postURI := f.post(t, server.URL) + bridge.replace(servedAggregate{URI: postURI, Upvotes: 3, UpdatedAt: f.now.Format(time.RFC3339Nano)}) + _, err := f.db.ExecContext(f.ctx, `UPDATE posts SET bridged_polled_at = $2 WHERE uri = $1`, postURI, f.now.Add(-time.Minute)) + require.NoError(t, err) + before := f.watermark(t, postURI) + require.True(t, before.Valid) + sentinel := errors.New("upvote notification write failed") + repo := pollerFailingNotificationRepo{Repository: f.notificationRepo(), err: sentinel} + poller := f.poller(t, []string{server.URL}, server.Client(), repo) + _, err = poller.Sweep(f.ctx) + require.ErrorIs(t, err, sentinel) + after := f.watermark(t, postURI) + require.True(t, after.Valid) + require.True(t, after.Time.UTC().Truncate(time.Microsecond).Equal(before.Time.UTC().Truncate(time.Microsecond))) +} diff --git a/internal/core/embeds/view.go b/internal/core/embeds/view.go index cc4c604..e4267e9 100644 --- a/internal/core/embeds/view.go +++ b/internal/core/embeds/view.go @@ -511,6 +511,10 @@ func blobCID(value interface{}) string { return "" } +// BlobCID exposes the same extraction rule to callers validating stored blobs +// before asking the view projector to turn them into URLs. +func BlobCID(value interface{}) string { return blobCID(value) } + // PostBlobCIDs returns the canonical, first-seen CIDs of post blobs served // through the image proxy. Malformed entries are skipped; video blobs are // served directly by the PDS, so only their thumbnails are included. diff --git a/internal/core/notifications/cursor.go b/internal/core/notifications/cursor.go new file mode 100644 index 0000000..b6e8f38 --- /dev/null +++ b/internal/core/notifications/cursor.go @@ -0,0 +1,56 @@ +package notifications + +import ( + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// maxEncodedCursorLength bounds the encoded cursor before decoding. The longest +// canonical cursor (year 9999, nanosecond fraction, minimum int64 ID) encodes to 68 characters. +const maxEncodedCursorLength = 96 + +// ErrInvalidCursor means a listNotifications cursor could not be decoded. +var ErrInvalidCursor = errors.New("invalid notification cursor") + +// Cursor is the keyset position (sort_at, id) of the last listed row. +type Cursor struct { + SortAt time.Time + ID int64 +} + +// EncodeCursor encodes a keyset position in its canonical UTC wire format. +func EncodeCursor(cursor Cursor) string { + position := cursor.SortAt.UTC().Format(time.RFC3339Nano) + "|" + strconv.FormatInt(cursor.ID, 10) + return base64.RawURLEncoding.EncodeToString([]byte(position)) +} + +// DecodeCursor rejects malformed keyset positions with ErrInvalidCursor. +func DecodeCursor(encoded string) (Cursor, error) { + if encoded == "" { + return Cursor{}, fmt.Errorf("decode notification cursor: %w: empty value", ErrInvalidCursor) + } + if len(encoded) > maxEncodedCursorLength { + return Cursor{}, fmt.Errorf("decode notification cursor: %w: longer than %d characters", ErrInvalidCursor, maxEncodedCursorLength) + } + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return Cursor{}, fmt.Errorf("decode notification cursor: %w: %v", ErrInvalidCursor, err) + } + fields := strings.SplitN(string(decoded), "|", 3) + if len(fields) != 2 { + return Cursor{}, fmt.Errorf("decode notification cursor: %w: expected two fields", ErrInvalidCursor) + } + sortAt, err := time.Parse(time.RFC3339Nano, fields[0]) + if err != nil { + return Cursor{}, fmt.Errorf("decode notification cursor timestamp: %w: %v", ErrInvalidCursor, err) + } + id, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return Cursor{}, fmt.Errorf("decode notification cursor ID: %w: %v", ErrInvalidCursor, err) + } + return Cursor{SortAt: sortAt.UTC(), ID: id}, nil +} diff --git a/internal/core/notifications/cursor_test.go b/internal/core/notifications/cursor_test.go new file mode 100644 index 0000000..7862848 --- /dev/null +++ b/internal/core/notifications/cursor_test.go @@ -0,0 +1,62 @@ +package notifications + +import ( + "encoding/base64" + "math" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNotificationCursorCodec(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + at time.Time + id int64 + }{ + {"microseconds", time.Date(2026, 9, 30, 12, 0, 0, 123456000, time.UTC), 42}, + {"maximum ID", time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC), math.MaxInt64}, + {"non-UTC location", time.Date(2026, 9, 30, 8, 0, 0, 1000, time.FixedZone("EDT", -4*60*60)), 7}, + {"longest canonical encoding", time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC), math.MinInt64}, + } { + t.Run(tc.name, func(t *testing.T) { + decoded, err := DecodeCursor(EncodeCursor(Cursor{SortAt: tc.at, ID: tc.id})) + require.NoError(t, err) + require.True(t, decoded.SortAt.Equal(tc.at), "decoded sort time: %s", decoded.SortAt) + require.Equal(t, tc.id, decoded.ID) + }) + } + + t.Run("canonical UTC wire encoding", func(t *testing.T) { + got := EncodeCursor(Cursor{ + SortAt: time.Date(2026, 9, 30, 8, 0, 0, 1000, time.FixedZone("EDT", -4*60*60)), + ID: 7, + }) + require.Equal(t, base64.RawURLEncoding.EncodeToString([]byte("2026-09-30T12:00:00.000001Z|7")), got) + }) + t.Run("independently encoded wire decoding", func(t *testing.T) { + decoded, err := DecodeCursor(base64.RawURLEncoding.EncodeToString([]byte("2026-09-30T12:00:00.123456Z|42"))) + require.NoError(t, err) + require.True(t, decoded.SortAt.Equal(time.Date(2026, 9, 30, 12, 0, 0, 123456000, time.UTC))) + require.Equal(t, int64(42), decoded.ID) + }) + + for _, tc := range []struct{ name, input string }{ + {"empty", ""}, + {"invalid base64", "!!!not-base64"}, + {"one field", base64.RawURLEncoding.EncodeToString([]byte("2026-09-30T12:00:00Z"))}, + {"three fields", base64.RawURLEncoding.EncodeToString([]byte("2026-09-30T12:00:00Z|1|2"))}, + {"invalid time", base64.RawURLEncoding.EncodeToString([]byte("yesterday|1"))}, + {"non-numeric ID", base64.RawURLEncoding.EncodeToString([]byte("2026-09-30T12:00:00Z|abc"))}, + {"fractional ID", base64.RawURLEncoding.EncodeToString([]byte("2026-09-30T12:00:00Z|1.5"))}, + {"over-length encoding of a valid position", base64.RawURLEncoding.EncodeToString([]byte("2026-09-30T12:00:00Z|" + strings.Repeat("0", 70) + "42"))}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := DecodeCursor(tc.input) + require.ErrorIs(t, err, ErrInvalidCursor) + }) + } +} diff --git a/internal/core/notifications/fanout.go b/internal/core/notifications/fanout.go new file mode 100644 index 0000000..eae9681 --- /dev/null +++ b/internal/core/notifications/fanout.go @@ -0,0 +1,688 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "Coves/internal/core/posts" + "Coves/internal/core/richtext" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// commentCollection is the comment record NSID. The jetstream package exports +// it, but importing jetstream here would be an import cycle. +const commentCollection = "social.coves.community.comment" + +// freshnessWindow is how far before the index transaction's time a record's +// createdAt may be and still notify. See docs/PRD_NOTIFICATIONS.md, Architecture, +// "Freshness gate". +const freshnessWindow = 7 * 24 * time.Hour + +// errMissingLegacyPost marks a direct reply to a legacy post whose posts row is +// missing. Such an event produces no notification at all. +var errMissingLegacyPost = errors.New("legacy post row is missing") + +// FanoutCommentCreate computes reply and mention notifications for a newly indexed +// comment. An invalid thread URI has no reply recipient. Mentions require a root +// URI that parses and names a post collection, because the row's root_post_uri +// must name a post. A reply to a legacy post whose posts row is missing produces +// no notification at all, mentions included (docs/PRD_NOTIFICATIONS.md, "Post +// author resolution"). A withdrawn or unindexed comment, root or parent also +// suppresses fan-out. +// richtext.MaxFacets bounds parsed mentions; at most +// MaxMentionsPerRecord surviving mention rows may belong to the record across +// creates and edits. Excess mentions are dropped and logged. Payload defects +// do not stop comment indexing. +// A nil bridgeHosts, including a nil pointer stored in the interface, trusts no +// host (see BridgeHostChecker). +func FanoutCommentCreate(ctx context.Context, lookups Lookups, bridgeHosts BridgeHostChecker, comment CommentRecord) ([]Intent, error) { + base := Intent{ + ActorDID: comment.AuthorDID, + RecordURI: comment.URI, + RecordCID: comment.CID, + RootPostURI: comment.RootURI, + RecordCreatedAt: comment.CreatedAt, + } + root, rootErr := syntax.ParseATURI(comment.RootURI) + // The row's root_post_uri must name a post. + rootIsPost := rootErr == nil && posts.IsPostCollection(root.Collection().String()) + if !rootIsPost { + return nil, nil + } + withdrawn, err := anyWithdrawn(ctx, lookups, comment.URI, comment.RootURI, comment.ParentURI) + if err != nil || withdrawn { + return nil, err + } + reply, err := resolveCommentReply(ctx, lookups, comment, base) + if errors.Is(err, errMissingLegacyPost) { + return nil, nil + } + if err != nil { + return nil, err + } + var mentions []string + mentionedDIDs, dropped := richtext.MentionedDIDs(comment.FacetsJSON) + if dropped > 0 { + slog.InfoContext(ctx, "comment mentions past the facet parse bound were dropped", + "record_uri", comment.URI, "dropped_mentions", dropped, "parse_bound", richtext.MaxFacets) + } + for _, did := range mentionedDIDs { + if did != comment.AuthorDID && did != reply.RecipientDID { + mentions = append(mentions, did) + } + } + replyRecipient := reply.RecipientDID + if replyRecipient == comment.AuthorDID { + replyRecipient = "" + } + if replyRecipient == "" && len(mentions) == 0 { + return nil, nil + } + allowed, err := notificationRecordAllowed(ctx, lookups, comment.CreatedAt) + if err != nil { + return nil, err + } + if !allowed { + return nil, nil + } + mentions, allowedRecipients, err := notificationCappedMentions(ctx, lookups, bridgeHosts, comment.AuthorDID, comment.URI, mentions, replyRecipient) + if err != nil { + return nil, err + } + var intents []Intent + if _, ok := allowedRecipients[reply.RecipientDID]; ok { + intents = append(intents, reply) + } + for _, did := range mentions { + if facts, ok := allowedRecipients[did]; ok && !facts.Community { + mention := base + mention.Reason = ReasonMention + mention.RecipientDID = did + intents = append(intents, mention) + } + } + return intents, nil +} + +// FanoutPostCreate computes mention notifications for a newly indexed postv2 +// post. Unparsable, non-postv2, or withdrawn posts produce no notifications. +// richtext.MaxFacets bounds parsed mentions; MaxMentionsPerRecord limits the +// record's surviving mention rows across creates and edits. Excess mentions +// are dropped and logged. A nil bridgeHosts trusts no host (see BridgeHostChecker). +func FanoutPostCreate(ctx context.Context, lookups Lookups, bridgeHosts BridgeHostChecker, post PostRecord) ([]Intent, error) { + uri, err := syntax.ParseATURI(post.URI) + if err != nil || uri.Collection().String() != posts.PostV2Collection { + return nil, nil + } + mentionedDIDs, dropped := richtext.MentionedDIDs(post.FacetsJSON) + if dropped > 0 { + slog.InfoContext(ctx, "post mentions past the facet parse bound were dropped", + "record_uri", post.URI, "dropped_mentions", dropped, "parse_bound", richtext.MaxFacets) + } + var candidates []string + for _, did := range mentionedDIDs { + if did != post.AuthorDID { + candidates = append(candidates, did) + } + } + if len(candidates) == 0 { + return nil, nil + } + withdrawn, err := anyWithdrawn(ctx, lookups, post.URI) + if err != nil || withdrawn { + return nil, err + } + allowed, err := notificationRecordAllowed(ctx, lookups, post.CreatedAt) + if err != nil { + return nil, err + } + if !allowed { + return nil, nil + } + candidates, _, err = notificationCappedMentions(ctx, lookups, bridgeHosts, post.AuthorDID, post.URI, candidates, "") + if err != nil { + return nil, err + } + var intents []Intent + for _, did := range candidates { + intents = append(intents, Intent{ + Reason: ReasonMention, RecipientDID: did, ActorDID: post.AuthorDID, + RecordURI: post.URI, RecordCID: post.CID, RootPostURI: post.URI, + RecordCreatedAt: post.CreatedAt, + }) + } + return intents, nil +} + +// FanoutPostEdit notifies newly added, non-author mentions on a postv2 edit. +// CreatedAt is the stored post creation time, while CID and FacetsJSON belong +// to the edit. Mentions already in the previous facets are not notified again; +// the remaining per-record mention budget also excludes existing recipients. +// Withdrawn posts are suppressed; the stored creation time must pass activation, +// and a known edit event time must be within the freshness window measured from +// index time. +func FanoutPostEdit(ctx context.Context, lookups Lookups, bridgeHosts BridgeHostChecker, post PostRecord, previousFacetsJSON string) ([]Intent, error) { + uri, err := syntax.ParseATURI(post.URI) + if err != nil || uri.Collection().String() != posts.PostV2Collection { + return nil, nil + } + added, dropped := addedMentionDIDs(post.FacetsJSON, previousFacetsJSON, post.AuthorDID) + if dropped > 0 { + slog.InfoContext(ctx, "post mentions past the facet parse bound were dropped", + "record_uri", post.URI, "dropped_mentions", dropped, "parse_bound", richtext.MaxFacets) + } + if len(added) == 0 { + return nil, nil + } + withdrawn, err := anyWithdrawn(ctx, lookups, post.URI) + if err != nil || withdrawn { + return nil, err + } + allowed, err := editRecordAllowed(ctx, lookups, post.URI, post.CreatedAt, post.EditEventTime) + if err != nil || !allowed { + return nil, err + } + mentions, _, err := notificationCappedMentions(ctx, lookups, bridgeHosts, post.AuthorDID, post.URI, added, "") + if err != nil { + return nil, err + } + var intents []Intent + for _, did := range mentions { + intents = append(intents, Intent{ + Reason: ReasonMention, RecipientDID: did, ActorDID: post.AuthorDID, + RecordURI: post.URI, RecordCID: post.CID, RootPostURI: post.URI, + RecordCreatedAt: post.CreatedAt, + }) + } + return intents, nil +} + +// FanoutCommentEdit notifies mentions added relative to the previous facets, +// excluding the reply recipient. In comment, CID and FacetsJSON come from the +// edit, while ParentURI, RootURI and CreatedAt come from the row read under lock. +// A withdrawn or unindexed comment, root or parent suppresses it. The stored +// createdAt must pass activation; a known edit event time must be within the freshness window +// measured from index time. richtext.MaxFacets bounds parsed mentions; +// MaxMentionsPerRecord limits surviving mention rows across +// creates and edits, with excess mentions dropped and logged. +func FanoutCommentEdit(ctx context.Context, lookups Lookups, bridgeHosts BridgeHostChecker, comment CommentRecord, previousFacetsJSON string) ([]Intent, error) { + root, err := syntax.ParseATURI(comment.RootURI) + if err != nil || !posts.IsPostCollection(root.Collection().String()) { + return nil, nil + } + added, dropped := addedMentionDIDs(comment.FacetsJSON, previousFacetsJSON, comment.AuthorDID) + if dropped > 0 { + slog.InfoContext(ctx, "comment mentions past the facet parse bound were dropped", + "record_uri", comment.URI, "dropped_mentions", dropped, "parse_bound", richtext.MaxFacets) + } + if len(added) == 0 { + return nil, nil + } + withdrawn, err := anyWithdrawn(ctx, lookups, comment.URI, comment.RootURI, comment.ParentURI) + if err != nil || withdrawn { + return nil, err + } + base := Intent{ + ActorDID: comment.AuthorDID, + RecordURI: comment.URI, + RecordCID: comment.CID, + RootPostURI: comment.RootURI, + RecordCreatedAt: comment.CreatedAt, + } + reply, err := resolveCommentReply(ctx, lookups, comment, base) + if errors.Is(err, errMissingLegacyPost) { + return nil, nil + } + if err != nil { + return nil, err + } + mentions := added[:0] + for _, did := range added { + if did != reply.RecipientDID { + mentions = append(mentions, did) + } + } + if len(mentions) == 0 { + return nil, nil + } + allowed, err := editRecordAllowed(ctx, lookups, comment.URI, comment.CreatedAt, comment.EditEventTime) + if err != nil || !allowed { + return nil, err + } + mentions, _, err = notificationCappedMentions(ctx, lookups, bridgeHosts, comment.AuthorDID, comment.URI, mentions, "") + if err != nil { + return nil, err + } + var intents []Intent + for _, did := range mentions { + mention := base + mention.Reason = ReasonMention + mention.RecipientDID = did + intents = append(intents, mention) + } + return intents, nil +} + +// addedMentionDIDs returns, in facet order, the non-author DIDs mentioned in +// newFacetsJSON but not in previousFacetsJSON, and how many mentions in +// newFacetsJSON were dropped past richtext.MaxFacets. DIDs past the previous +// parse bound were never notified, so they intentionally count as added. +func addedMentionDIDs(newFacetsJSON, previousFacetsJSON, authorDID string) ([]string, int) { + newDIDs, dropped := richtext.MentionedDIDs(newFacetsJSON) + previousDIDs, _ := richtext.MentionedDIDs(previousFacetsJSON) + previouslyMentioned := make(map[string]bool, len(previousDIDs)) + for _, did := range previousDIDs { + previouslyMentioned[did] = true + } + var added []string + for _, did := range newDIDs { + if did != authorDID && !previouslyMentioned[did] { + added = append(added, did) + } + } + return added, dropped +} + +// notificationCappedMentions selects eligible mentions in facet order using +// the remaining budget, while looking up a reply recipient without charging it +// a mention slot. The caller has already checked record gates and excluded +// self and reply-recipient mentions. +func notificationCappedMentions(ctx context.Context, lookups Lookups, bridgeHosts BridgeHostChecker, actorDID, recordURI string, mentions []string, replyRecipient string) ([]string, map[string]RecipientFacts, error) { + budget := 0 + if len(mentions) > 0 { + existing, err := lookups.ExistingMentionRecipients(ctx, recordURI) + if err != nil { + return nil, nil, fmt.Errorf("look up notification existing mention recipients: %w", err) + } + budget = MaxMentionsPerRecord - len(existing) + existingRecipients := make(map[string]bool, len(existing)) + for _, did := range existing { + existingRecipients[did] = true + } + remaining := mentions[:0] + for _, did := range mentions { + if !existingRecipients[did] { + remaining = append(remaining, did) + } + } + mentions = remaining + } + candidates := make([]string, 0, len(mentions)+1) + if replyRecipient != "" { + candidates = append(candidates, replyRecipient) + } + if budget > 0 { + candidates = append(candidates, mentions...) + } + var allowedRecipients map[string]RecipientFacts + if len(candidates) > 0 { + var err error + allowedRecipients, err = notificationAllowedRecipients(ctx, lookups, bridgeHosts, actorDID, candidates) + if err != nil { + return nil, nil, err + } + } + var selected []string + dropped := 0 + if budget <= 0 { + // At a full budget, no recipient facts are needed to cut all + // remaining candidates. Already-notified recipients were removed. + dropped = len(mentions) + } else { + for _, did := range mentions { + if facts, ok := allowedRecipients[did]; ok && !facts.Community { + if len(selected) < budget { + selected = append(selected, did) + } else { + dropped++ + } + } + } + } + if dropped > 0 { + slog.InfoContext(ctx, "mentions over the per-record notification cap were dropped", + "record_uri", recordURI, "dropped_mentions", dropped, "mention_cap", MaxMentionsPerRecord) + } + return selected, allowedRecipients, nil +} + +// CommentReplySubject resolves the reply subject from the comment's threading +// using the same rules as create fan-out. Missing legacy posts and unsupported +// threading have no subject and return ""; lookup failures propagate. +func CommentReplySubject(ctx context.Context, lookups Lookups, comment CommentRecord) (string, error) { + reply, err := resolveCommentReply(ctx, lookups, comment, Intent{}) + if errors.Is(err, errMissingLegacyPost) { + return "", nil + } + if err != nil { + return "", err + } + return reply.SubjectURI, nil +} + +// resolveCommentReply sets the reply reason, recipient and subject on reply when +// the comment has a reply recipient, and returns reply unchanged otherwise. It +// returns errMissingLegacyPost only for a direct reply to a legacy post whose +// posts row has disappeared. +func resolveCommentReply(ctx context.Context, lookups Lookups, comment CommentRecord, reply Intent) (Intent, error) { + root, rootErr := syntax.ParseATURI(comment.RootURI) + rootIsPost := rootErr == nil && posts.IsPostCollection(root.Collection().String()) + if rootErr == nil && comment.ParentURI == comment.RootURI { + switch root.Collection().String() { + case posts.PostV2Collection: + reply.Reason = ReasonPostReply + reply.RecipientDID = root.Authority().String() + reply.SubjectURI = comment.RootURI + case posts.LegacyPostCollection: + authorDID, found, err := lookups.LegacyPostAuthor(ctx, comment.RootURI) + if err != nil { + return Intent{}, fmt.Errorf("look up notification legacy post author: %w", err) + } + if !found { + return Intent{}, errMissingLegacyPost + } + reply.Reason = ReasonPostReply + reply.RecipientDID = authorDID + reply.SubjectURI = comment.RootURI + } + } else if rootIsPost { + parent, err := syntax.ParseATURI(comment.ParentURI) + if err == nil && parent.Collection().String() == commentCollection { + reply.Reason = ReasonCommentReply + reply.RecipientDID = parent.Authority().String() + reply.SubjectURI = comment.ParentURI + } + } + return reply, nil +} + +// notificationRecordAllowed checks the activation and freshness gates once for +// a record, before reading any recipient facts. +func notificationRecordAllowed(ctx context.Context, lookups Lookups, createdAt time.Time) (bool, error) { + activatedAt, err := lookups.ActivatedAt(ctx) + if err != nil { + return false, fmt.Errorf("look up notification activation time: %w", err) + } + if createdAt.Before(activatedAt) { + return false, nil + } + indexTime, err := lookups.IndexTime(ctx) + if err != nil { + return false, fmt.Errorf("look up notification index time: %w", err) + } + if createdAt.Before(indexTime.Add(-freshnessWindow)) { + return false, nil + } + return true, nil +} + +// anyWithdrawn checks the distinct reference URIs together before recipient, +// activation, or freshness reads. Any state but ReferenceLive withdraws, +// including ReferenceUnindexed. Comment fan-out passes the comment's own URI as +// well, since a removal can predate indexing or outlive a re-create at that URI. +func anyWithdrawn(ctx context.Context, lookups Lookups, uris ...string) (bool, error) { + distinct := make([]string, 0, len(uris)) + for _, uri := range uris { + found := false + for _, previous := range distinct { + if previous == uri { + found = true + break + } + } + if !found { + distinct = append(distinct, uri) + } + } + states, err := lookups.ReferenceStates(ctx, distinct) + if err != nil { + return false, fmt.Errorf("look up notification reference states: %w", err) + } + for _, uri := range distinct { + if states[uri] != ReferenceLive { + return true, nil + } + } + return false, nil +} + +// editRecordAllowed is the gate shared by post and comment edit mentions: the +// stored createdAt must pass activation, and a known edit event time must be +// within the freshness window measured from index time. A zero editEventTime +// means the event has no Jetstream timestamp, so freshness cannot suppress it. +func editRecordAllowed(ctx context.Context, lookups Lookups, recordURI string, createdAt, editEventTime time.Time) (bool, error) { + activatedAt, err := lookups.ActivatedAt(ctx) + if err != nil { + return false, fmt.Errorf("look up notification activation time: %w", err) + } + if createdAt.Before(activatedAt) { + slog.DebugContext(ctx, "edit mentions suppressed: record predates notification activation", + "record_uri", recordURI) + return false, nil + } + if editEventTime.IsZero() { + return true, nil + } + indexTime, err := lookups.IndexTime(ctx) + if err != nil { + return false, fmt.Errorf("look up notification index time: %w", err) + } + if editEventTime.Before(indexTime.Add(-freshnessWindow)) { + slog.DebugContext(ctx, "edit mentions suppressed: edit event is outside the freshness window", + "record_uri", recordURI) + return false, nil + } + return true, nil +} + +// notificationAllowedRecipients reads all candidate facts at once and retains +// indexed recipients eligible for notification. A nil bridgeHosts trusts no host. +func notificationAllowedRecipients(ctx context.Context, lookups Lookups, bridgeHosts BridgeHostChecker, actorDID string, candidates []string) (map[string]RecipientFacts, error) { + facts, err := lookups.RecipientFacts(ctx, actorDID, candidates) + if err != nil { + return nil, fmt.Errorf("look up notification recipient facts: %w", err) + } + allowed := make(map[string]RecipientFacts, len(facts)) + for _, did := range candidates { + fact, found := facts[did] + if !found || fact.Erased || fact.Aggregator || (bridgeHosts != nil && bridgeHosts.TrustsPDS(fact.PDSURL)) || fact.BlockedWithActor { + continue + } + allowed[did] = fact + } + return allowed, nil +} + +// FanoutVoteCreate bumps the group only for a voter's first qualifying upvote +// on a subject, or asks the repository to delete it if empty when a resolved +// vote does not qualify. After resolving the subject, it checks direction, +// comment root, self-vote, withdrawn references, activation and freshness, +// voter eligibility, and recipient eligibility before checking for another +// upvote by this voter. +// The earlier-upvote lookup is last: only a vote that would otherwise bump +// needs the index probe, while repeat voters pass through the other lookups. +// Unresolvable subjects give no intent and no error; lookup errors propagate. +func FanoutVoteCreate(ctx context.Context, lookups Lookups, bridgeHosts BridgeHostChecker, vote VoteRecord) (UpvoteGroupIntent, error) { + author, resolved, err := resolveVoteSubjectAuthor(ctx, lookups, vote.SubjectURI) + if err != nil { + return UpvoteGroupIntent{}, err + } + if !resolved { + return UpvoteGroupIntent{}, nil + } + deleteIfEmpty := UpvoteGroupIntent{ + Action: UpvoteGroupDeleteIfEmpty, RecipientDID: author, SubjectURI: vote.SubjectURI, + } + if vote.Direction != "up" { + return deleteIfEmpty, nil + } + rootPostURI, hasRootPost := voteBumpRootPostURI(vote) + if !hasRootPost { + return deleteIfEmpty, nil + } + if author == vote.VoterDID { + return deleteIfEmpty, nil + } + withdrawn, err := anyWithdrawn(ctx, lookups, vote.SubjectURI, rootPostURI) + if err != nil { + return UpvoteGroupIntent{}, err + } + if withdrawn { + return deleteIfEmpty, nil + } + allowed, err := notificationRecordAllowed(ctx, lookups, vote.CreatedAt) + if err != nil { + return UpvoteGroupIntent{}, err + } + if !allowed { + return deleteIfEmpty, nil + } + if vote.VoterErased { + return deleteIfEmpty, nil + } + aggregator, err := lookups.IsAggregator(ctx, vote.VoterDID) + if err != nil { + return UpvoteGroupIntent{}, fmt.Errorf("look up notification voter aggregator status: %w", err) + } + if aggregator { + return deleteIfEmpty, nil + } + recipients, err := notificationAllowedRecipients(ctx, lookups, bridgeHosts, vote.VoterDID, []string{author}) + if err != nil { + return UpvoteGroupIntent{}, err + } + if _, ok := recipients[author]; !ok { + return deleteIfEmpty, nil + } + earlierUpvote, err := lookups.EarlierUpvoteExists(ctx, vote.VoterDID, vote.SubjectURI, vote.URI) + if err != nil { + return UpvoteGroupIntent{}, fmt.Errorf("look up notification voter earlier upvote: %w", err) + } + if earlierUpvote { + return deleteIfEmpty, nil + } + return UpvoteGroupIntent{ + Action: UpvoteGroupBump, RecipientDID: author, + SubjectURI: vote.SubjectURI, RootPostURI: rootPostURI, + }, nil +} + +// resolveVoteSubjectAuthor identifies the author of a supported vote subject, +// who receives its upvote group: a postv2's URI authority, a legacy post's row +// author, or a comment's URI authority whatever its root. Unparsable URIs, other +// collections and legacy posts without a row are unresolved. +func resolveVoteSubjectAuthor(ctx context.Context, lookups Lookups, subjectURI string) (author string, resolved bool, err error) { + subject, err := syntax.ParseATURI(subjectURI) + if err != nil { + return "", false, nil + } + switch subject.Collection().String() { + case posts.PostV2Collection, commentCollection: + return subject.Authority().String(), true, nil + case posts.LegacyPostCollection: + author, found, err := lookups.LegacyPostAuthor(ctx, subjectURI) + if err != nil { + return "", false, fmt.Errorf("look up notification legacy post author: %w", err) + } + return author, found, nil + default: + return "", false, nil + } +} + +// voteBumpRootPostURI returns the root post a bump records for a vote on a +// resolved subject: the post itself, or a comment's stored root when that +// root parses and names a post collection. Otherwise the vote cannot bump. +func voteBumpRootPostURI(vote VoteRecord) (string, bool) { + return bumpRootPostURI(vote.SubjectURI, vote.SubjectRootURI) +} + +func bumpRootPostURI(subjectURI, subjectRootURI string) (string, bool) { + subject, err := syntax.ParseATURI(subjectURI) + if err != nil { + return "", false + } + switch subject.Collection().String() { + case posts.PostV2Collection, posts.LegacyPostCollection: + return subjectURI, true + case commentCollection: + root, err := syntax.ParseATURI(subjectRootURI) + if err != nil || !posts.IsPostCollection(root.Collection().String()) { + return "", false + } + return subjectRootURI, true + default: + return "", false + } +} + +// FanoutVoteRemoval asks the repository to delete an empty group when a vote +// leaves a resolvable subject's vote set, regardless of vote eligibility or a +// comment's root. It reads only vote.SubjectURI. +// Unresolvable subjects give no intent and no error; lookup errors propagate. +func FanoutVoteRemoval(ctx context.Context, lookups Lookups, vote VoteRecord) (UpvoteGroupIntent, error) { + author, resolved, err := resolveVoteSubjectAuthor(ctx, lookups, vote.SubjectURI) + if err != nil { + return UpvoteGroupIntent{}, err + } + if !resolved { + return UpvoteGroupIntent{}, nil + } + return UpvoteGroupIntent{ + Action: UpvoteGroupDeleteIfEmpty, RecipientDID: author, SubjectURI: vote.SubjectURI, + }, nil +} + +// BridgedUpvoteChange is one observed change in an item's stored bridged upvote total. +type BridgedUpvoteChange struct { + SubjectURI string + SubjectRootURI string + PreviousUpvotes int + Upvotes int + // PeakUpvotes is the item's stored bridged upvote high-water mark. The + // Jetstream record path writes the count without raising it, so it can lag + // PreviousUpvotes; the effective mark is max(PeakUpvotes, PreviousUpvotes). + PeakUpvotes int +} + +// FanoutBridgedUpvoteChange resolves the subject author first. A decrease asks +// to delete an empty group. A bump needs a total above the item's high-water +// mark; decreases and re-rises up to it change only the displayed count. A new +// high checks for a post root, withdrawn subject or root, and an eligible +// recipient (with no actor). The high-water mark is backfilled from the stored +// total at launch; new highs are exempt from activation cutoff, 7-day +// freshness and asOf age checks, even for old content. +func FanoutBridgedUpvoteChange(ctx context.Context, lookups Lookups, bridgeHosts BridgeHostChecker, change BridgedUpvoteChange) (UpvoteGroupIntent, error) { + author, resolved, err := resolveVoteSubjectAuthor(ctx, lookups, change.SubjectURI) + if err != nil || !resolved { + return UpvoteGroupIntent{}, err + } + if change.Upvotes < change.PreviousUpvotes { + return UpvoteGroupIntent{Action: UpvoteGroupDeleteIfEmpty, RecipientDID: author, SubjectURI: change.SubjectURI}, nil + } + if change.Upvotes <= max(change.PeakUpvotes, change.PreviousUpvotes) { + return UpvoteGroupIntent{}, nil + } + rootPostURI, hasRootPost := bumpRootPostURI(change.SubjectURI, change.SubjectRootURI) + if !hasRootPost { + return UpvoteGroupIntent{}, nil + } + withdrawn, err := anyWithdrawn(ctx, lookups, change.SubjectURI, rootPostURI) + if err != nil || withdrawn { + return UpvoteGroupIntent{}, err + } + recipients, err := notificationAllowedRecipients(ctx, lookups, bridgeHosts, "", []string{author}) + if err != nil { + return UpvoteGroupIntent{}, err + } + if _, allowed := recipients[author]; !allowed { + return UpvoteGroupIntent{}, nil + } + return UpvoteGroupIntent{ + Action: UpvoteGroupBump, RecipientDID: author, + SubjectURI: change.SubjectURI, RootPostURI: rootPostURI, + }, nil +} diff --git a/internal/core/notifications/fanout_blocks_test.go b/internal/core/notifications/fanout_blocks_test.go new file mode 100644 index 0000000..591783d --- /dev/null +++ b/internal/core/notifications/fanout_blocks_test.go @@ -0,0 +1,45 @@ +package notifications + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFanoutCommentCreate_BlocksEitherDirection(t *testing.T) { + const recipientDID = "did:plc:timegaterecipient" + const actorDID = "did:plc:timegatecommenter" + const thirdPartyDID = "did:plc:timegatethirdparty" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + block commentFanoutBlock + wantIntent bool + }{ + {"recipient_blocks_actor", commentFanoutBlock{recipientDID, actorDID}, false}, + {"actor_blocks_recipient", commentFanoutBlock{actorDID, recipientDID}, false}, + {"actor_blocks_third_party", commentFanoutBlock{actorDID, thirdPartyDID}, true}, + {"third_party_blocks_recipient", commentFanoutBlock{thirdPartyDID, recipientDID}, true}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationTimeGateComment(createdAt) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{recipientDID: true}, + blocks: map[commentFanoutBlock]bool{test.block: true}, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + if !test.wantIntent { + require.Empty(t, intents, "a block between actor and recipient must suppress their postReply") + return + } + require.Equal(t, []Intent{{ + Reason: ReasonPostReply, RecipientDID: recipientDID, ActorDID: actorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: comment.RootURI, + RootPostURI: comment.RootURI, RecordCreatedAt: createdAt, + }}, intents, "a block involving a third party must not suppress the postReply") + }) + } +} diff --git a/internal/core/notifications/fanout_bridged_upvote_peak_test.go b/internal/core/notifications/fanout_bridged_upvote_peak_test.go new file mode 100644 index 0000000..95203d5 --- /dev/null +++ b/internal/core/notifications/fanout_bridged_upvote_peak_test.go @@ -0,0 +1,67 @@ +package notifications + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFanoutBridgedUpvoteChange_PeakRule(t *testing.T) { + for _, test := range []struct { + name string + change BridgedUpvoteChange + want UpvoteGroupIntent + }{ + { + name: "post_below_peak", + change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 3, PeakUpvotes: 5, Upvotes: 4}, + want: UpvoteGroupIntent{}, + }, + { + name: "post_returns_to_peak", + change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 3, PeakUpvotes: 5, Upvotes: 5}, + want: UpvoteGroupIntent{}, + }, + { + name: "post_exceeds_peak", + change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 3, PeakUpvotes: 5, Upvotes: 6}, + want: UpvoteGroupIntent{Action: UpvoteGroupBump, RecipientDID: voteFanoutAuthor, SubjectURI: voteFanoutPost, RootPostURI: voteFanoutPost}, + }, + { + name: "comment_exceeds_peak", + change: BridgedUpvoteChange{SubjectURI: voteFanoutCommenterComment, SubjectRootURI: voteFanoutPost, PreviousUpvotes: 3, PeakUpvotes: 5, Upvotes: 6}, + want: UpvoteGroupIntent{Action: UpvoteGroupBump, RecipientDID: voteFanoutCommenter, SubjectURI: voteFanoutCommenterComment, RootPostURI: voteFanoutPost}, + }, + { + name: "missing_peak_equal_to_previous", + change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 5, PeakUpvotes: 0, Upvotes: 5}, + want: UpvoteGroupIntent{}, + }, + { + name: "missing_peak_exceeds_previous", + change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 5, PeakUpvotes: 0, Upvotes: 6}, + want: UpvoteGroupIntent{Action: UpvoteGroupBump, RecipientDID: voteFanoutAuthor, SubjectURI: voteFanoutPost, RootPostURI: voteFanoutPost}, + }, + { + name: "decrease_from_peak", + change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 5, PeakUpvotes: 7, Upvotes: 3}, + want: UpvoteGroupIntent{Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutAuthor, SubjectURI: voteFanoutPost}, + }, + } { + t.Run(test.name, func(t *testing.T) { + lookups := commentFanoutLookups{indexedUsers: map[string]bool{ + voteFanoutAuthor: true, voteFanoutCommenter: true, + }} + if test.want == (UpvoteGroupIntent{}) { + poison := errors.New("peak-suppressed total must not look up withdrawn references or recipients") + lookups.referenceStatesError = poison + lookups.recipientFactsError = poison + } + intent, err := FanoutBridgedUpvoteChange(context.Background(), lookups, nil, test.change) + require.NoError(t, err) + require.Equal(t, test.want, intent) + }) + } +} diff --git a/internal/core/notifications/fanout_bridged_upvote_test.go b/internal/core/notifications/fanout_bridged_upvote_test.go new file mode 100644 index 0000000..eef8645 --- /dev/null +++ b/internal/core/notifications/fanout_bridged_upvote_test.go @@ -0,0 +1,142 @@ +package notifications + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFanoutBridgedUpvoteChange_Intents(t *testing.T) { + const unsupportedSubject = "at://" + voteFanoutAuthor + "/app.bsky.feed.post/post" + poison := errors.New("unrelated lookup must not run") + for _, test := range []struct { + name string + change BridgedUpvoteChange + setup func(*commentFanoutLookups) + bridgeHosts BridgeHostChecker + want UpvoteGroupIntent + wantActor bool + poisonGates bool + }{ + { + name: "postv2_increase", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 0, Upvotes: 3}, + want: UpvoteGroupIntent{Action: UpvoteGroupBump, RecipientDID: voteFanoutAuthor, SubjectURI: voteFanoutPost, RootPostURI: voteFanoutPost}, + wantActor: true, poisonGates: true, + }, + { + name: "comment_increase_goes_to_commenter_not_root_author", + change: BridgedUpvoteChange{SubjectURI: voteFanoutCommenterComment, SubjectRootURI: voteFanoutPost, PreviousUpvotes: 3, Upvotes: 4}, + want: UpvoteGroupIntent{Action: UpvoteGroupBump, RecipientDID: voteFanoutCommenter, SubjectURI: voteFanoutCommenterComment, RootPostURI: voteFanoutPost}, + wantActor: true, poisonGates: true, + }, + { + name: "legacy_post_resolves_row_author_not_community", change: BridgedUpvoteChange{SubjectURI: voteFanoutLegacyPost, PreviousUpvotes: 0, Upvotes: 3}, + want: UpvoteGroupIntent{Action: UpvoteGroupBump, RecipientDID: voteFanoutAuthor, SubjectURI: voteFanoutLegacyPost, RootPostURI: voteFanoutLegacyPost}, + wantActor: true, poisonGates: true, + }, + {name: "equal_total", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 4, Upvotes: 4}, want: UpvoteGroupIntent{}}, + {name: "decrease", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 4, Upvotes: 2}, + want: UpvoteGroupIntent{Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutAuthor, SubjectURI: voteFanoutPost}}, + {name: "decrease_to_zero", change: BridgedUpvoteChange{SubjectURI: voteFanoutLegacyPost, PreviousUpvotes: 3, Upvotes: 0}, + want: UpvoteGroupIntent{Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutAuthor, SubjectURI: voteFanoutLegacyPost}}, + {name: "withdrawn_decrease_ignores_withdrawal_and_recipient", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 4, Upvotes: 2}, + setup: func(lookups *commentFanoutLookups) { + lookups.referenceStates = map[string]ReferenceState{voteFanoutPost: ReferenceDeleted} + lookups.referenceStatesError = poison + lookups.recipientFactsError = poison + }, + want: UpvoteGroupIntent{Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutAuthor, SubjectURI: voteFanoutPost}}, + {name: "unparsable_subject_increase", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost + "/", PreviousUpvotes: 0, Upvotes: 3}, want: UpvoteGroupIntent{}}, + {name: "unparsable_subject_decrease", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost + "/", PreviousUpvotes: 3, Upvotes: 0}, want: UpvoteGroupIntent{}}, + {name: "other_collection_increase", change: BridgedUpvoteChange{SubjectURI: unsupportedSubject, PreviousUpvotes: 0, Upvotes: 3}, want: UpvoteGroupIntent{}}, + {name: "other_collection_decrease", change: BridgedUpvoteChange{SubjectURI: unsupportedSubject, PreviousUpvotes: 3, Upvotes: 0}, want: UpvoteGroupIntent{}}, + {name: "missing_legacy_post_increase", change: BridgedUpvoteChange{SubjectURI: voteFanoutLegacyPost, PreviousUpvotes: 0, Upvotes: 3}, + setup: func(lookups *commentFanoutLookups) { delete(lookups.legacyPostAuthors, voteFanoutLegacyPost) }, want: UpvoteGroupIntent{}}, + {name: "missing_legacy_post_decrease", change: BridgedUpvoteChange{SubjectURI: voteFanoutLegacyPost, PreviousUpvotes: 3, Upvotes: 0}, + setup: func(lookups *commentFanoutLookups) { delete(lookups.legacyPostAuthors, voteFanoutLegacyPost) }, want: UpvoteGroupIntent{}}, + {name: "comment_empty_root_increase", change: BridgedUpvoteChange{SubjectURI: voteFanoutCommenterComment, PreviousUpvotes: 0, Upvotes: 3}, want: UpvoteGroupIntent{}}, + {name: "comment_unparsable_root_increase", change: BridgedUpvoteChange{SubjectURI: voteFanoutCommenterComment, SubjectRootURI: voteFanoutPost + "/", PreviousUpvotes: 0, Upvotes: 3}, want: UpvoteGroupIntent{}}, + {name: "comment_nonpost_root_increase", change: BridgedUpvoteChange{SubjectURI: voteFanoutCommenterComment, SubjectRootURI: voteFanoutComment, PreviousUpvotes: 0, Upvotes: 3}, want: UpvoteGroupIntent{}}, + {name: "comment_without_post_root_decrease", change: BridgedUpvoteChange{SubjectURI: voteFanoutCommenterComment, PreviousUpvotes: 3, Upvotes: 0}, + want: UpvoteGroupIntent{Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutCommenter, SubjectURI: voteFanoutCommenterComment}}, + {name: "recipient_not_indexed", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 0, Upvotes: 3}, + setup: func(lookups *commentFanoutLookups) { delete(lookups.indexedUsers, voteFanoutAuthor) }, want: UpvoteGroupIntent{}}, + {name: "recipient_erased", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 0, Upvotes: 3}, + setup: func(lookups *commentFanoutLookups) { lookups.erasedAccounts = map[string]bool{voteFanoutAuthor: true} }, want: UpvoteGroupIntent{}}, + {name: "recipient_aggregator", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 0, Upvotes: 3}, + setup: func(lookups *commentFanoutLookups) { + lookups.aggregatorAccounts = map[string]bool{voteFanoutAuthor: true} + }, want: UpvoteGroupIntent{}}, + {name: "recipient_on_trusted_bridge_pds", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 0, Upvotes: 3}, + setup: func(lookups *commentFanoutLookups) { + lookups.userPDSURLs = map[string]string{voteFanoutAuthor: voteFanoutBridgePDS} + }, + bridgeHosts: ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{voteFanoutBridgePDS: true}}, want: UpvoteGroupIntent{}}, + {name: "withdrawn_subject", change: BridgedUpvoteChange{SubjectURI: voteFanoutPost, PreviousUpvotes: 0, Upvotes: 3}, + setup: func(lookups *commentFanoutLookups) { + lookups.referenceStates = map[string]ReferenceState{voteFanoutPost: ReferenceRemovedByModerator} + }, want: UpvoteGroupIntent{}}, + {name: "withdrawn_comment_root", change: BridgedUpvoteChange{SubjectURI: voteFanoutCommenterComment, SubjectRootURI: voteFanoutPost, PreviousUpvotes: 0, Upvotes: 3}, + setup: func(lookups *commentFanoutLookups) { + lookups.referenceStates = map[string]ReferenceState{voteFanoutPost: ReferenceDeleted} + }, want: UpvoteGroupIntent{}}, + } { + t.Run(test.name, func(t *testing.T) { + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{voteFanoutAuthor: true, voteFanoutCommenter: true, "did:plc:upvotefanoutcommunity": true}, + legacyPostAuthors: map[string]string{voteFanoutLegacyPost: voteFanoutAuthor}, + } + if test.setup != nil { + test.setup(&lookups) + } + if test.poisonGates { + lookups.activatedAtError = poison + lookups.indexTimeError = poison + lookups.earlierUpvoteError = poison + lookups.isAggregatorError = poison + } + var actors []string + lookups.recipientFactsActors = &actors + intent, err := FanoutBridgedUpvoteChange(context.Background(), lookups, test.bridgeHosts, test.change) + require.NoError(t, err) + require.Equal(t, test.want, intent) + if test.wantActor { + require.Equal(t, []string{""}, actors, "bridged totals have no actor DID") + } + }) + } +} + +func TestFanoutBridgedUpvoteChange_LookupErrorsAreWrapped(t *testing.T) { + for _, test := range []struct { + name string + subject string + setError func(*commentFanoutLookups, error) + }{ + {"LegacyPostAuthor", voteFanoutLegacyPost, func(lookups *commentFanoutLookups, err error) { lookups.legacyPostAuthorError = err }}, + {"ReferenceStates", voteFanoutPost, func(lookups *commentFanoutLookups, err error) { lookups.referenceStatesError = err }}, + {"RecipientFacts", voteFanoutPost, func(lookups *commentFanoutLookups, err error) { lookups.recipientFactsError = err }}, + } { + t.Run(test.name, func(t *testing.T) { + sentinel := errors.New("bridged upvote lookup failed") + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{voteFanoutAuthor: true}, + legacyPostAuthors: map[string]string{voteFanoutLegacyPost: voteFanoutAuthor}, + } + var actors []string + lookups.recipientFactsActors = &actors + test.setError(&lookups, sentinel) + intent, err := FanoutBridgedUpvoteChange(context.Background(), lookups, nil, BridgedUpvoteChange{ + SubjectURI: test.subject, PreviousUpvotes: 0, Upvotes: 3, + }) + require.ErrorIs(t, err, sentinel) + require.NotEqual(t, sentinel, err, "lookup error must be wrapped") + require.Equal(t, UpvoteGroupIntent{}, intent) + if test.name == "RecipientFacts" { + require.Equal(t, []string{""}, actors, "bridged totals have no actor DID") + } + }) + } +} diff --git a/internal/core/notifications/fanout_edit_event_time_test.go b/internal/core/notifications/fanout_edit_event_time_test.go new file mode 100644 index 0000000..10eb146 --- /dev/null +++ b/internal/core/notifications/fanout_edit_event_time_test.go @@ -0,0 +1,105 @@ +package notifications + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFanoutCommentEdit_EventTimeFreshnessBoundary(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + eventTime time.Time + wantMention bool + wantIndexCall int + }{ + {"older than seven days", indexTime.Add(-7*24*time.Hour - time.Second), false, 1}, + {"exactly seven days", indexTime.Add(-7 * 24 * time.Hour), true, 1}, + {"inside seven days", indexTime.Add(-7*24*time.Hour + time.Second), true, 1}, + {"no event time on a thirty-day-old comment", time.Time{}, true, 0}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationEditComment() + comment.CreatedAt = indexTime.Add(-30 * 24 * time.Hour) + comment.EditEventTime = test.eventTime + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + var indexCalls int + var recipientFactsCalls [][]string + var existingCalls []string + lookups := notificationEditLookups(comment) + lookups.activatedAt = indexTime.Add(-40 * 24 * time.Hour) + lookups.indexTime = indexTime + lookups.indexTimeCalls = &indexCalls + lookups.recipientFactsCalls = &recipientFactsCalls + lookups.existingMentionRecipientsCalls = &existingCalls + if test.eventTime.IsZero() { + lookups.indexTimeError = errors.New("no event time must not require IndexTime") + } + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + if test.wantMention { + require.Equal(t, notificationEditExpectedMentions(comment, mentionRecipientDDID), intents, + "a qualifying edit notifies despite the stored comment being thirty days old") + require.Equal(t, [][]string{{mentionRecipientDDID}}, recipientFactsCalls) + } else { + require.Nil(t, intents, "an edit event older than seven days must not notify") + require.Empty(t, recipientFactsCalls, "stale edits must stop before reading recipient facts") + require.Empty(t, existingCalls, "stale edits must stop before reading the mention budget") + } + require.Equal(t, test.wantIndexCall, indexCalls) + }) + } +} + +func TestFanoutCommentEdit_EventTimeIndexLookupErrorIsWrapped(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + comment := notificationEditComment() + comment.CreatedAt = indexTime.Add(-30 * 24 * time.Hour) + comment.EditEventTime = indexTime.Add(-time.Hour) + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + sentinel := errors.New("edit index time lookup failed") + var indexCalls int + var recipientFactsCalls [][]string + lookups := notificationEditLookups(comment) + lookups.activatedAt = indexTime.Add(-40 * 24 * time.Hour) + lookups.indexTime = indexTime + lookups.indexTimeError = sentinel + lookups.indexTimeCalls = &indexCalls + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.ErrorIs(t, err, sentinel) + require.ErrorContains(t, err, "look up notification") + require.Empty(t, intents) + require.Equal(t, 1, indexCalls) + require.Empty(t, recipientFactsCalls, "failed freshness lookup must not read recipient facts") +} + +func TestFanoutCommentEdit_PreActivationSkipsFreshnessAndBudget(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + comment := notificationEditComment() + comment.CreatedAt = indexTime.Add(-2 * time.Hour) + // A fresh event time means a freshness check would read IndexTime. + comment.EditEventTime = indexTime + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + var activationCalls, indexCalls int + var recipientFactsCalls [][]string + var existingCalls []string + lookups := notificationEditLookups(comment) + lookups.activatedAt = indexTime.Add(-time.Hour) + lookups.indexTime = indexTime + lookups.activatedAtCalls = &activationCalls + lookups.indexTimeCalls = &indexCalls + lookups.recipientFactsCalls = &recipientFactsCalls + lookups.existingMentionRecipientsCalls = &existingCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + require.Nil(t, intents, "the stored createdAt predates activation despite a fresh edit event") + require.Equal(t, 1, activationCalls) + require.Zero(t, indexCalls, "activation must be checked before edit freshness") + require.Empty(t, existingCalls, "a pre-activation edit must stop before reading the mention budget") + require.Empty(t, recipientFactsCalls) +} diff --git a/internal/core/notifications/fanout_edit_test.go b/internal/core/notifications/fanout_edit_test.go new file mode 100644 index 0000000..cdb9082 --- /dev/null +++ b/internal/core/notifications/fanout_edit_test.go @@ -0,0 +1,415 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "Coves/internal/core/richtext" + + "github.com/stretchr/testify/require" +) + +const editMentionRecipientFDID = "did:plc:editmentionrecipientf" + +func notificationEditComment() CommentRecord { + comment := notificationTimeGateComment(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC)) + comment.CID = "bafyreieditedcomment" + return comment +} + +func notificationEditLookups(comment CommentRecord) commentFanoutLookups { + return commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionReplyRecipientDID: true, mentionRecipientBDID: true, + mentionRecipientDDID: true, editMentionRecipientFDID: true, + }, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } +} + +func notificationEditExpectedMentions(comment CommentRecord, dids ...string) []Intent { + var intents []Intent + for _, did := range dids { + intents = append(intents, notificationExpectedIntent(comment, ReasonMention, did, "")) + } + return intents +} + +func TestFanoutCommentEdit_DiffsStoredAndEditedFacets(t *testing.T) { + for _, test := range []struct { + name, stored string + mentioned []string + want []string + }{ + {"adds D, not previously mentioned B", notificationMentionFacets(mentionRecipientBDID), + []string{mentionRecipientBDID, mentionRecipientDDID}, []string{mentionRecipientDDID}}, + {"preserves new facet order", notificationMentionFacets(mentionRecipientBDID), + []string{mentionRecipientDDID, editMentionRecipientFDID, mentionRecipientBDID}, + []string{mentionRecipientDDID, editMentionRecipientFDID}}, + {"SQL NULL stored facets", "", []string{mentionRecipientDDID}, []string{mentionRecipientDDID}}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationEditComment() + comment.FacetsJSON = notificationMentionFacets(test.mentioned...) + lookups := notificationEditLookups(comment) + var activationCalls, indexTimeCalls int + var recipientFactsCalls [][]string + lookups.activatedAtCalls = &activationCalls + lookups.indexTimeCalls = &indexTimeCalls + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, test.stored) + require.NoError(t, err) + require.Equal(t, notificationEditExpectedMentions(comment, test.want...), intents, + "only edit-added DIDs receive complete mention intents in facet order") + require.Equal(t, 1, activationCalls, "check activation once for an edit with added mentions") + require.Zero(t, indexTimeCalls, "edits have no createdAt freshness lookup") + require.Equal(t, [][]string{test.want}, recipientFactsCalls, + "one recipient-facts lookup containing only added eligible candidates") + }) + } + for _, test := range []struct { + name, stored string + mentioned []string + }{ + {"unchanged facets", notificationMentionFacets(mentionRecipientBDID), []string{mentionRecipientBDID}}, + {"removed B, kept D", notificationMentionFacets(mentionRecipientBDID, mentionRecipientDDID), []string{mentionRecipientDDID}}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationEditComment() + comment.FacetsJSON = notificationMentionFacets(test.mentioned...) + // Even a legacy root must not be looked up if nothing was added. + comment.RootURI = "at://did:plc:editcommunity/social.coves.community.post/post" + comment.ParentURI = comment.RootURI + var legacyCalls, activationCalls, indexTimeCalls int + var recipientFactsCalls [][]string + lookups := notificationEditLookups(comment) + lookups.legacyPostAuthorCalls = &legacyCalls + lookups.activatedAtCalls = &activationCalls + lookups.indexTimeCalls = &indexTimeCalls + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, test.stored) + require.NoError(t, err) + require.Empty(t, intents, "keeping or removing a stored mention creates no notification or retraction") + require.Zero(t, legacyCalls, "no added candidates means no legacy author lookup") + require.Zero(t, activationCalls) + require.Zero(t, indexTimeCalls) + require.Empty(t, recipientFactsCalls) + }) + } +} + +func TestFanoutCommentEdit_SuppressesResolvedReplyRecipient(t *testing.T) { + for _, test := range []struct { + name string + configure func(*CommentRecord, *commentFanoutLookups) + replyRecipient string + }{ + {"postv2 post author", func(comment *CommentRecord, _ *commentFanoutLookups) { + comment.RootURI = "at://" + mentionRecipientBDID + "/social.coves.community.postv2/post" + comment.ParentURI = comment.RootURI + }, mentionRecipientBDID}, + {"nested comment parent, not root author", func(comment *CommentRecord, _ *commentFanoutLookups) { + comment.RootURI = "at://" + mentionReplyRecipientDID + "/social.coves.community.postv2/post" + comment.ParentURI = "at://" + mentionRecipientBDID + "/social.coves.community.comment/parent" + }, mentionRecipientBDID}, + {"legacy post row author, not URI authority", func(comment *CommentRecord, lookups *commentFanoutLookups) { + comment.RootURI = "at://did:plc:editcommunity/social.coves.community.post/post" + comment.ParentURI = comment.RootURI + lookups.indexedUsers["did:plc:editcommunity"] = true + lookups.legacyPostAuthors = map[string]string{comment.RootURI: mentionRecipientBDID} + }, mentionRecipientBDID}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationEditComment() + lookups := notificationEditLookups(comment) + test.configure(&comment, &lookups) + comment.FacetsJSON = notificationMentionFacets(test.replyRecipient, mentionRecipientDDID) + var recipientFactsCalls [][]string + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + require.Equal(t, notificationEditExpectedMentions(comment, mentionRecipientDDID), intents, + "the resolved reply recipient never receives a second notification, even without a reply row") + require.Equal(t, [][]string{{mentionRecipientDDID}}, recipientFactsCalls, + "resolve and remove the reply recipient before checking recipient facts") + }) + } + + t.Run("only the reply recipient was added", func(t *testing.T) { + comment := notificationEditComment() + comment.FacetsJSON = notificationMentionFacets(mentionReplyRecipientDID) + lookups := notificationEditLookups(comment) + var activationCalls, indexTimeCalls int + var recipientFactsCalls [][]string + lookups.activatedAtCalls, lookups.indexTimeCalls = &activationCalls, &indexTimeCalls + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + require.Empty(t, intents) + require.Zero(t, activationCalls, "remove the reply recipient before activation") + require.Zero(t, indexTimeCalls) + require.Empty(t, recipientFactsCalls, "remove the reply recipient before reading facts") + }) + t.Run("only the actor was added", func(t *testing.T) { + comment := notificationEditComment() + comment.RootURI = "at://did:plc:editcommunity/social.coves.community.post/post" + comment.ParentURI = comment.RootURI + comment.FacetsJSON = notificationMentionFacets(comment.AuthorDID) + lookups := notificationEditLookups(comment) + lookups.indexedUsers[comment.AuthorDID] = true + var legacyCalls, activationCalls, indexTimeCalls int + var recipientFactsCalls [][]string + lookups.legacyPostAuthorCalls = &legacyCalls + lookups.activatedAtCalls, lookups.indexTimeCalls = &activationCalls, &indexTimeCalls + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + require.Empty(t, intents) + require.Zero(t, legacyCalls, "self-mentions exit before resolving a legacy reply") + require.Zero(t, activationCalls) + require.Zero(t, indexTimeCalls) + require.Empty(t, recipientFactsCalls) + }) +} + +func TestFanoutCommentEdit_FiltersIneligibleRecipients(t *testing.T) { + const ineligibleDID = mentionRecipientMDID + for _, test := range []struct { + name string + candidate string + configure func(*commentFanoutLookups, string) + bridgeHosts BridgeHostChecker + }{ + {"community", ineligibleDID, func(lookups *commentFanoutLookups, _ string) { + lookups.communityAccounts = map[string]bool{ineligibleDID: true} + }, nil}, + {"actor", "", func(*commentFanoutLookups, string) {}, nil}, + {"recipient blocks actor", ineligibleDID, func(lookups *commentFanoutLookups, actor string) { + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: ineligibleDID, blockedDID: actor}: true} + }, nil}, + {"actor blocks recipient", ineligibleDID, func(lookups *commentFanoutLookups, actor string) { + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: actor, blockedDID: ineligibleDID}: true} + }, nil}, + {"unindexed", ineligibleDID, func(lookups *commentFanoutLookups, _ string) { + delete(lookups.indexedUsers, ineligibleDID) + }, nil}, + {"erased", ineligibleDID, func(lookups *commentFanoutLookups, _ string) { + lookups.erasedAccounts = map[string]bool{ineligibleDID: true} + }, nil}, + {"aggregator", ineligibleDID, func(lookups *commentFanoutLookups, _ string) { + lookups.aggregatorAccounts = map[string]bool{ineligibleDID: true} + }, nil}, + {"trusted bridge host", ineligibleDID, func(lookups *commentFanoutLookups, _ string) { + lookups.userPDSURLs = map[string]string{ineligibleDID: "https://bridge.test"} + }, ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{"https://bridge.test": true}}}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationEditComment() + candidate := test.candidate + if candidate == "" { + candidate = comment.AuthorDID + } + comment.FacetsJSON = notificationMentionFacets(candidate, mentionRecipientDDID) + lookups := notificationEditLookups(comment) + lookups.indexedUsers[candidate] = true + test.configure(&lookups, comment.AuthorDID) + var recipientFactsCalls [][]string + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, test.bridgeHosts, comment, "") + require.NoError(t, err) + require.Equal(t, notificationEditExpectedMentions(comment, mentionRecipientDDID), intents, + "ineligible candidate cannot receive an edit mention; eligible D must") + if candidate == comment.AuthorDID { + require.Equal(t, [][]string{{mentionRecipientDDID}}, recipientFactsCalls, + "actor must be excluded before the one recipient-facts read") + } else { + require.Equal(t, [][]string{{candidate, mentionRecipientDDID}}, recipientFactsCalls, + "check the ineligible candidate alongside the eligible control") + } + }) + } +} + +func TestFanoutCommentEdit_ActivationWithoutRecordFreshness(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + createdAt time.Time + activatedAt time.Time + wantMention bool + }{ + {"thirty-day-old record after activation", indexTime.Add(-30 * 24 * time.Hour), indexTime.Add(-40 * 24 * time.Hour), true}, + {"exactly at activation", indexTime.Add(-time.Hour), indexTime.Add(-time.Hour), true}, + {"before activation despite new index time", indexTime.Add(-2 * time.Hour), indexTime.Add(-time.Hour), false}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationTimeGateComment(test.createdAt) + comment.CID = "bafyreieditedcomment" + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + lookups := notificationEditLookups(comment) + lookups.activatedAt, lookups.indexTime = test.activatedAt, indexTime + // A stale record must not be rejected by IndexTime (which is not an edit gate). + lookups.indexTimeError = errors.New("edit must not check record freshness") + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + if !test.wantMention { + require.Empty(t, intents, "a pre-activation stored record cannot notify from an edit") + return + } + require.Equal(t, notificationEditExpectedMentions(comment, mentionRecipientDDID), intents, + "an edit is gated by record activation, never the original record's age") + }) + } +} + +func TestFanoutCommentEdit_CapsNewAndStoredMentionsIndependently(t *testing.T) { + const count = richtext.MaxFacets + 1 + mentioned := make([]string, count) + indexedUsers := map[string]bool{mentionReplyRecipientDID: true} + for index := range mentioned { + mentioned[index] = fmt.Sprintf("did:plc:editcapped%03d", index) + indexedUsers[mentioned[index]] = true + } + t.Run("new mentions stop at the first 200", func(t *testing.T) { + comment := notificationEditComment() + comment.FacetsJSON = notificationMentionFacetsPerFacet(richtext.MaxFeaturesPerFacet, mentioned...) + lookups := notificationEditLookups(comment) + lookups.indexedUsers = indexedUsers + var recipientFactsCalls [][]string + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + require.Equal(t, MaxMentionsPerRecord, len(intents), "only the first ten distinct edit mentions are sent") + for index, did := range mentioned[:MaxMentionsPerRecord] { + require.Equal(t, notificationExpectedIntent(comment, ReasonMention, did, ""), intents[index], + "edit mention %d must retain facet order and every intent field", index) + } + require.Equal(t, [][]string{mentioned[:richtext.MaxFacets]}, recipientFactsCalls, + "read facts once for only the capped candidates") + }) + t.Run("stored cap excludes the former 201st DID", func(t *testing.T) { + comment := notificationEditComment() + editedOrder := append([]string{mentioned[richtext.MaxFacets]}, mentioned[:richtext.MaxFacets]...) + comment.FacetsJSON = notificationMentionFacetsPerFacet(richtext.MaxFeaturesPerFacet, editedOrder...) + lookups := notificationEditLookups(comment) + lookups.indexedUsers = indexedUsers + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, + notificationMentionFacetsPerFacet(richtext.MaxFeaturesPerFacet, mentioned...)) + require.NoError(t, err) + require.Equal(t, notificationEditExpectedMentions(comment, mentioned[richtext.MaxFacets]), intents, + "X201 is newly added because it was outside the stored 200-DID cap") + }) + t.Run("new DID past the new cap is not added although the previous record lacked it", func(t *testing.T) { + comment := notificationEditComment() + comment.FacetsJSON = notificationMentionFacetsPerFacet(richtext.MaxFeaturesPerFacet, mentioned...) + lookups := notificationEditLookups(comment) + lookups.indexedUsers = indexedUsers + var recipientFactsCalls [][]string + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, + notificationMentionFacetsPerFacet(richtext.MaxFeaturesPerFacet, mentioned[:richtext.MaxFacets]...)) + require.NoError(t, err) + require.Empty(t, intents, "cap the new facets before the diff, so the 201st new DID is dropped, not added") + require.Empty(t, recipientFactsCalls, "nothing was added, so no recipient facts are read") + }) + t.Run("reply recipient counts toward the new cap before it is filtered", func(t *testing.T) { + comment := notificationEditComment() + editedOrder := append([]string{mentionReplyRecipientDID}, mentioned[:richtext.MaxFacets]...) + comment.FacetsJSON = notificationMentionFacetsPerFacet(richtext.MaxFeaturesPerFacet, editedOrder...) + lookups := notificationEditLookups(comment) + lookups.indexedUsers = indexedUsers + var recipientFactsCalls [][]string + lookups.recipientFactsCalls = &recipientFactsCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + require.Equal(t, MaxMentionsPerRecord, len(intents), "the reply recipient does not use a mention notification slot") + require.Equal(t, notificationEditExpectedMentions(comment, mentioned[:MaxMentionsPerRecord]...), intents, + "the reply recipient counts toward the 200-DID parse bound, but not the ten notification slots") + require.Equal(t, [][]string{mentioned[:richtext.MaxFacets-1]}, recipientFactsCalls, + "read facts once for the 199 capped mentions left after removing the reply recipient") + }) +} + +func TestFanoutCommentEdit_RequiresPostRootAndResolvesLegacyOnlyForDirectReplies(t *testing.T) { + for _, test := range []struct { + name string + rootURI string + parentURI string + wantMention bool + wantLegacyLookup int + }{ + {"unparsable root", "at://did:plc:editcommunity/social.coves.community.postv2/post/", "", false, 0}, + {"non-post collection root", "at://did:plc:editcommunity/social.coves.community.comment/root", "", false, 0}, + {"missing legacy root replied to directly", "at://did:plc:editcommunity/social.coves.community.post/missing", "", false, 1}, + {"nested reply under missing legacy root", "at://did:plc:editcommunity/social.coves.community.post/missing", + "at://did:plc:editparent/social.coves.community.comment/parent", true, 0}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationEditComment() + comment.RootURI, comment.ParentURI = test.rootURI, test.parentURI + if comment.ParentURI == "" { + comment.ParentURI = comment.RootURI + } + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + lookups := notificationEditLookups(comment) + lookups.indexedUsers["did:plc:editcommunity"] = true + lookups.indexedUsers["did:plc:editparent"] = true + var legacyCalls int + lookups.legacyPostAuthorCalls = &legacyCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err, "an unsupported root or missing posts row is not a transient failure") + if test.wantMention { + require.Equal(t, notificationEditExpectedMentions(comment, mentionRecipientDDID), intents, + "nested reply under legacy root still notifies newly mentioned D") + } else { + require.Empty(t, intents, "no mention may use a non-post root or a missing direct legacy post") + } + require.Equal(t, test.wantLegacyLookup, legacyCalls, + "look up the legacy posts row only for a direct reply") + }) + } +} + +func TestFanoutCommentEdit_WrapsLookupErrors(t *testing.T) { + for _, test := range []struct { + name string + configure func(*CommentRecord, *commentFanoutLookups, error) + }{ + {"reference states", func(_ *CommentRecord, lookups *commentFanoutLookups, failure error) { + lookups.referenceStatesError = failure + }}, + {"legacy post author", func(comment *CommentRecord, lookups *commentFanoutLookups, failure error) { + comment.RootURI = "at://did:plc:editcommunity/social.coves.community.post/post" + comment.ParentURI = comment.RootURI + lookups.legacyPostAuthorError = failure + }}, + {"activation", func(_ *CommentRecord, lookups *commentFanoutLookups, failure error) { + lookups.activatedAtError = failure + }}, + {"recipient facts", func(_ *CommentRecord, lookups *commentFanoutLookups, failure error) { + lookups.recipientFactsError = failure + }}, + {"existing mention recipients", func(_ *CommentRecord, lookups *commentFanoutLookups, failure error) { + lookups.existingMentionRecipientsError = failure + }}, + } { + t.Run(test.name, func(t *testing.T) { + failure := errors.New("injected edit lookup failure") + comment := notificationEditComment() + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + lookups := notificationEditLookups(comment) + test.configure(&comment, &lookups, failure) + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.ErrorIs(t, err, failure, "an edit with eligible D must propagate the failed lookup") + if test.name == "reference states" { + require.ErrorContains(t, err, "look up notification reference states") + } + require.ErrorContains(t, err, "look up notification", "wrap edit lookup errors with notification context") + require.Empty(t, intents, "failed lookups must not return partial intents") + }) + } +} diff --git a/internal/core/notifications/fanout_gates_test.go b/internal/core/notifications/fanout_gates_test.go new file mode 100644 index 0000000..3195946 --- /dev/null +++ b/internal/core/notifications/fanout_gates_test.go @@ -0,0 +1,185 @@ +package notifications + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func notificationTimeGateComment(createdAt time.Time) CommentRecord { + postURI := "at://did:plc:timegaterecipient/social.coves.community.postv2/post" + return CommentRecord{ + URI: "at://did:plc:timegatecommenter/social.coves.community.comment/reply", + CID: "bafyreitimegatereply", AuthorDID: "did:plc:timegatecommenter", + ParentURI: postURI, RootURI: postURI, CreatedAt: createdAt, + } +} + +func TestFanoutCommentCreate_TimeGateBoundaries(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + createdAt time.Time + activatedAt time.Time + indexTime time.Time + wantIntent bool + }{ + {"before_activation", indexTime, indexTime.Add(time.Nanosecond), indexTime.Add(time.Minute), false}, + {"exactly_at_activation", indexTime, indexTime, indexTime.Add(time.Minute), true}, + {"older_than_seven_days", indexTime.Add(-7*24*time.Hour - time.Microsecond), indexTime.Add(-30 * 24 * time.Hour), indexTime, false}, + {"exactly_seven_days_old", indexTime.Add(-7 * 24 * time.Hour), indexTime.Add(-30 * 24 * time.Hour), indexTime, true}, + } { + t.Run(test.name, func(t *testing.T) { + var activationCalls, indexTimeCalls int + comment := notificationTimeGateComment(test.createdAt) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{"did:plc:timegaterecipient": true}, + activatedAt: test.activatedAt, + indexTime: test.indexTime, + activatedAtCalls: &activationCalls, + indexTimeCalls: &indexTimeCalls, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + if !test.wantIntent { + require.Empty(t, intents, "a reply outside the time gate must not notify an indexed recipient") + return + } + require.Equal(t, []Intent{{ + Reason: ReasonPostReply, RecipientDID: "did:plc:timegaterecipient", ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: comment.RootURI, + RootPostURI: comment.RootURI, RecordCreatedAt: comment.CreatedAt, + }}, intents, "a reply exactly on the boundary still notifies its post author") + require.GreaterOrEqual(t, activationCalls, 1, "the activation boundary must come from ActivatedAt") + require.GreaterOrEqual(t, indexTimeCalls, 1, "the seven-day boundary must come from IndexTime") + }) + } +} + +func TestFanoutCommentCreate_FutureCreatedAtRetainsOnlyDisplayTimestamp(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + comment := notificationTimeGateComment(indexTime.Add(time.Hour)) + var activationCalls, indexTimeCalls int + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{"did:plc:timegaterecipient": true}, + activatedAt: indexTime.Add(-time.Hour), + indexTime: indexTime, + activatedAtCalls: &activationCalls, + indexTimeCalls: &indexTimeCalls, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{{ + Reason: ReasonPostReply, RecipientDID: "did:plc:timegaterecipient", ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: comment.RootURI, + RootPostURI: comment.RootURI, RecordCreatedAt: comment.CreatedAt, + }}, intents, "future createdAt is display-only and must not change identity or ordering fields") + require.GreaterOrEqual(t, activationCalls, 1, "the future record must pass through the activation gate") + require.GreaterOrEqual(t, indexTimeCalls, 1, "the future record must pass through the index-time gate") +} + +func TestFanoutCommentCreate_OneTimeGateAndRecipientFactsLookupPerRecord(t *testing.T) { + const recipientDID = "did:plc:timegaterecipient" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + comment := notificationTimeGateComment(createdAt) + var activationCalls, indexTimeCalls int + var recipientFactsCalls [][]string + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{recipientDID: true}, + activatedAt: createdAt.Add(-time.Minute), + indexTime: createdAt.Add(time.Minute), + activatedAtCalls: &activationCalls, + indexTimeCalls: &indexTimeCalls, + recipientFactsCalls: &recipientFactsCalls, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{{ + Reason: ReasonPostReply, RecipientDID: recipientDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: comment.RootURI, + RootPostURI: comment.RootURI, RecordCreatedAt: createdAt, + }}, intents, "eligible reply must still produce B's postReply") + require.Equal(t, 1, activationCalls, "check the activation gate once per record") + require.Equal(t, 1, indexTimeCalls, "check the freshness gate once per record") + require.Equal(t, [][]string{{recipientDID}}, recipientFactsCalls, + "read recipient facts once with exactly B as the candidate") +} + +func TestFanoutCommentCreate_AllLookupErrorsAreWrapped(t *testing.T) { + const recipientDID = "did:plc:timegaterecipient" + const legacyPostURI = "at://did:plc:timegatecommunity/social.coves.community.post/post" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + legacyPost bool + setError func(*commentFanoutLookups, error) + }{ + {"ReferenceStates", false, func(lookups *commentFanoutLookups, err error) { lookups.referenceStatesError = err }}, + {"LegacyPostAuthor", true, func(lookups *commentFanoutLookups, err error) { lookups.legacyPostAuthorError = err }}, + {"ActivatedAt", false, func(lookups *commentFanoutLookups, err error) { lookups.activatedAtError = err }}, + {"IndexTime", false, func(lookups *commentFanoutLookups, err error) { lookups.indexTimeError = err }}, + {"RecipientFacts", false, func(lookups *commentFanoutLookups, err error) { + lookups.recipientFactsError = err + }}, + {"ExistingMentionRecipients", false, func(lookups *commentFanoutLookups, err error) { + lookups.existingMentionRecipientsError = err + }}, + } { + t.Run(test.name, func(t *testing.T) { + sentinel := errors.New("notification lookup failed") + comment := notificationTimeGateComment(createdAt) + // Indexed D's mention would survive a fan-out that kept going after + // the failed lookup, so an empty result proves fan-out stopped. + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{recipientDID: true, mentionRecipientDDID: true}, + legacyPostAuthors: map[string]string{legacyPostURI: recipientDID}, + } + if test.legacyPost { + comment.ParentURI, comment.RootURI = legacyPostURI, legacyPostURI + } + test.setError(&lookups, sentinel) + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.ErrorIs(t, err, sentinel, "a failing lookup must abort fan-out") + if test.name == "ReferenceStates" { + require.ErrorContains(t, err, "look up notification reference states") + } + require.NotEqual(t, sentinel, err, "the fan-out error must wrap the failing lookup with context") + require.Empty(t, intents, "a failing lookup must never produce an intent") + }) + } +} + +func TestFanoutVoteCreate_AllLookupErrorsAreWrapped(t *testing.T) { + for _, test := range []struct { + name, subject string + setError func(*commentFanoutLookups, error) + }{ + {"ReferenceStates", voteFanoutPost, func(lookups *commentFanoutLookups, err error) { lookups.referenceStatesError = err }}, + {"LegacyPostAuthor", voteFanoutLegacyPost, func(lookups *commentFanoutLookups, err error) { lookups.legacyPostAuthorError = err }}, + {"ActivatedAt", voteFanoutPost, func(lookups *commentFanoutLookups, err error) { lookups.activatedAtError = err }}, + {"IndexTime", voteFanoutPost, func(lookups *commentFanoutLookups, err error) { lookups.indexTimeError = err }}, + {"IsAggregator", voteFanoutPost, func(lookups *commentFanoutLookups, err error) { lookups.isAggregatorError = err }}, + {"RecipientFacts", voteFanoutPost, func(lookups *commentFanoutLookups, err error) { lookups.recipientFactsError = err }}, + {"EarlierUpvoteExists", voteFanoutPost, func(lookups *commentFanoutLookups, err error) { lookups.earlierUpvoteError = err }}, + } { + t.Run(test.name, func(t *testing.T) { + sentinel := errors.New("vote notification lookup failed") + lookups, vote := qualifyingVoteFanout() + vote.SubjectURI = test.subject + lookups.activatedAt = vote.CreatedAt.Add(-time.Minute) + lookups.indexTime = vote.CreatedAt.Add(time.Minute) + test.setError(&lookups, sentinel) + intent, err := FanoutVoteCreate(context.Background(), lookups, nil, vote) + require.NotEqual(t, UpvoteGroupBump, intent.Action, "a failed lookup must not bump the group") + require.ErrorIs(t, err, sentinel, "a failed %s lookup must abort vote fan-out", test.name) + if test.name == "ReferenceStates" { + require.ErrorContains(t, err, "look up notification reference states") + } + require.NotEqual(t, sentinel, err, "the %s error must be wrapped with context", test.name) + }) + } +} diff --git a/internal/core/notifications/fanout_mention_cap_test.go b/internal/core/notifications/fanout_mention_cap_test.go new file mode 100644 index 0000000..f87d9e5 --- /dev/null +++ b/internal/core/notifications/fanout_mention_cap_test.go @@ -0,0 +1,257 @@ +package notifications + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func notificationCapRecipients(count int) []string { + recipients := make([]string, count) + for index := range recipients { + recipients[index] = fmt.Sprintf("did:plc:notificationcap%03d", index) + } + return recipients +} + +func notificationCapIndexedUsers(recipientDIDs ...string) map[string]bool { + indexedUsers := map[string]bool{mentionReplyRecipientDID: true} + for _, did := range recipientDIDs { + indexedUsers[did] = true + } + return indexedUsers +} + +func notificationCapReplyIntent(comment CommentRecord) Intent { + return notificationExpectedIntent(comment, ReasonPostReply, mentionReplyRecipientDID, comment.RootURI) +} + +func TestFanoutCommentCreate_MentionsUseOnlyTenSlotsAfterReply(t *testing.T) { + comment := notificationMentionComment() + recipients := notificationCapRecipients(12) + comment.FacetsJSON = notificationMentionFacets(recipients...) + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: notificationCapIndexedUsers(recipients...), + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + existingMentionRecipientsCalls: &existingCalls, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + want := []Intent{notificationCapReplyIntent(comment)} + for _, did := range recipients[:MaxMentionsPerRecord] { + want = append(want, notificationExpectedIntent(comment, ReasonMention, did, "")) + } + require.Equal(t, len(want), len(intents), "reply plus ten mention slots") + require.Equal(t, want, intents, "the reply does not consume a slot; only the first ten mentions do") + require.Equal(t, []string{comment.URI}, existingCalls, "read the record's existing mentions once") +} + +func TestFanoutCommentCreate_IneligibleMentionsDoNotConsumeSlots(t *testing.T) { + comment := notificationMentionComment() + recipients := notificationCapRecipients(12) + const ( + blockedByAuthorDID = "did:plc:capblockedbyauthor" + blockedAuthorDID = "did:plc:capblockedauthor" + erasedDID = "did:plc:caperased" + communityDID = "did:plc:capcommunity" + ) + initial := []string{blockedByAuthorDID, blockedAuthorDID, erasedDID, communityDID, comment.AuthorDID, mentionReplyRecipientDID} + comment.FacetsJSON = notificationMentionFacets(append(initial, recipients...)...) + lookups := commentFanoutLookups{ + indexedUsers: notificationCapIndexedUsers(append(initial, recipients...)...), + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + erasedAccounts: map[string]bool{erasedDID: true}, + communityAccounts: map[string]bool{communityDID: true}, + blocks: map[commentFanoutBlock]bool{ + {blockerDID: comment.AuthorDID, blockedDID: blockedByAuthorDID}: true, + {blockerDID: blockedAuthorDID, blockedDID: comment.AuthorDID}: true, + }, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + want := []Intent{notificationCapReplyIntent(comment)} + for _, did := range recipients[:MaxMentionsPerRecord] { + want = append(want, notificationExpectedIntent(comment, ReasonMention, did, "")) + } + require.Equal(t, len(want), len(intents), "excluded facets must not consume mention slots") + require.Equal(t, want, intents, "excluded facets before eligible users cannot spend mention slots") +} + +func TestFanoutCommentCreate_PreexistingHolderDoesNotUseRemainingBudget(t *testing.T) { + comment := notificationMentionComment() + recipients := notificationCapRecipients(10) + comment.FacetsJSON = notificationMentionFacets(append([]string{mentionRecipientBDID}, recipients...)...) + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: notificationCapIndexedUsers(append([]string{mentionRecipientBDID}, recipients...)...), + activatedAt: comment.CreatedAt.Add(-time.Minute), indexTime: comment.CreatedAt.Add(time.Minute), + existingMentionRecipients: map[string][]string{ + comment.URI: {mentionRecipientBDID, mentionRecipientDDID}, + }, + existingMentionRecipientsCalls: &existingCalls, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + want := []Intent{notificationCapReplyIntent(comment)} + for _, did := range recipients[:MaxMentionsPerRecord-2] { + want = append(want, notificationExpectedIntent(comment, ReasonMention, did, "")) + } + require.Equal(t, len(want), len(intents), "two existing rows leave eight new mention slots") + require.Equal(t, want, intents, "the already-notified recipient is excluded before filling the eight remaining slots") + require.Equal(t, []string{comment.URI}, existingCalls) +} + +func TestFanoutCommentCreate_FullMentionBudgetPreservesReplyWithoutMentionFactsLookup(t *testing.T) { + comment := notificationMentionComment() + comment.FacetsJSON = notificationMentionFacets(mentionRecipientBDID, mentionRecipientDDID) + var recipientFactsCalls [][]string + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: notificationCapIndexedUsers(mentionRecipientBDID, mentionRecipientDDID), + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + existingMentionRecipients: map[string][]string{comment.URI: notificationCapRecipients(MaxMentionsPerRecord)}, + existingMentionRecipientsCalls: &existingCalls, + recipientFactsCalls: &recipientFactsCalls, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, 1, len(intents), "a full mention budget still allows the reply") + require.Equal(t, []Intent{notificationCapReplyIntent(comment)}, intents, + "a full mention budget does not consume or suppress the reply intent") + require.Equal(t, [][]string{{mentionReplyRecipientDID}}, recipientFactsCalls, + "only the reply recipient needs a facts lookup at a full mention budget") + require.Equal(t, []string{comment.URI}, existingCalls) +} + +func TestFanoutCommentCreate_NoMentionCandidatesSkipExistingMentionsLookup(t *testing.T) { + comment := notificationMentionComment() + comment.FacetsJSON = "" + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: notificationCapIndexedUsers(), + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + existingMentionRecipientsCalls: &existingCalls, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{notificationCapReplyIntent(comment)}, intents) + require.Empty(t, existingCalls, "a reply without mention candidates must not read the mention budget") +} + +func TestFanoutCommentEdit_UsesRemainingRecordMentionBudget(t *testing.T) { + for _, test := range []struct { + name string + existingCount int + wantCount int + }{ + {"seven_used_three_remain", 7, 3}, + {"nine_used_one_remains", 9, 1}, + {"ten_used_none_remain", 10, 0}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationEditComment() + added := notificationCapRecipients(5) + comment.FacetsJSON = notificationMentionFacets(added...) + lookups := notificationEditLookups(comment) + lookups.indexedUsers = notificationCapIndexedUsers(added...) + lookups.existingMentionRecipients = map[string][]string{comment.URI: notificationCapRecipients(20)[10 : 10+test.existingCount]} + var recipientFactsCalls [][]string + var existingCalls []string + lookups.recipientFactsCalls = &recipientFactsCalls + lookups.existingMentionRecipientsCalls = &existingCalls + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + require.Equal(t, test.wantCount, len(intents), "the edit must respect the remaining mention budget") + require.Equal(t, notificationEditExpectedMentions(comment, added[:test.wantCount]...), intents, + "an edit uses only the slots remaining after all earlier edits and re-creates") + if test.wantCount == 0 { + require.Empty(t, recipientFactsCalls, "do not read recipient facts when the mention budget is full") + } + require.Equal(t, []string{comment.URI}, existingCalls) + }) + } +} + +func TestFanoutCommentEdit_ReaddedExistingHolderDoesNotUseSlot(t *testing.T) { + comment := notificationEditComment() + added := notificationCapRecipients(4) + comment.FacetsJSON = notificationMentionFacets(append([]string{mentionRecipientBDID}, added...)...) + lookups := notificationEditLookups(comment) + lookups.indexedUsers = notificationCapIndexedUsers(append([]string{mentionRecipientBDID}, added...)...) + lookups.existingMentionRecipients = map[string][]string{comment.URI: []string{mentionRecipientBDID}} + intents, err := FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + require.NoError(t, err) + require.Equal(t, len(added), len(intents), "an already-notified recipient must not receive another intent") + require.Equal(t, notificationEditExpectedMentions(comment, added...), intents, + "a previously notified DID removed by an edit cannot be notified again when re-added") +} + +func TestFanoutPostCreate_MentionsUseOnlyTenSlots(t *testing.T) { + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + recipients := notificationCapRecipients(12) + post := notificationPostWithMentions(createdAt, recipients...) + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: notificationCapIndexedUsers(recipients...), + activatedAt: createdAt.Add(-time.Minute), + indexTime: createdAt.Add(time.Minute), + existingMentionRecipientsCalls: &existingCalls, + } + intents, err := FanoutPostCreate(context.Background(), lookups, nil, post) + require.NoError(t, err) + var want []Intent + for _, did := range recipients[:MaxMentionsPerRecord] { + want = append(want, notificationPostMentionIntent(post, did)) + } + require.Equal(t, len(want), len(intents), "a post has at most ten mention slots") + require.Equal(t, want, intents, "a new post notifies only the first ten eligible mentions") + require.Equal(t, []string{post.URI}, existingCalls) +} + +func TestFanoutPostCreate_FullMentionBudgetSkipsRecipientFacts(t *testing.T) { + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + post := notificationPostWithMentions(createdAt, mentionRecipientBDID, mentionRecipientDDID) + var recipientFactsCalls [][]string + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: notificationCapIndexedUsers(mentionRecipientBDID, mentionRecipientDDID), + activatedAt: createdAt.Add(-time.Minute), + indexTime: createdAt.Add(time.Minute), + existingMentionRecipients: map[string][]string{post.URI: notificationCapRecipients(MaxMentionsPerRecord)}, + existingMentionRecipientsCalls: &existingCalls, + recipientFactsCalls: &recipientFactsCalls, + } + intents, err := FanoutPostCreate(context.Background(), lookups, nil, post) + require.NoError(t, err) + require.Empty(t, intents, "ten existing mention rows exhaust this post's budget") + require.Empty(t, recipientFactsCalls, "no recipient facts should be read at a full mention budget") + require.Equal(t, []string{post.URI}, existingCalls, "a full budget must be established by looking up the existing rows") +} + +func TestFanoutPostCreate_ExistingHolderDoesNotUseSlot(t *testing.T) { + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + recipients := notificationCapRecipients(10) + post := notificationPostWithMentions(createdAt, append([]string{mentionRecipientBDID}, recipients...)...) + lookups := commentFanoutLookups{ + indexedUsers: notificationCapIndexedUsers(append([]string{mentionRecipientBDID}, recipients...)...), + activatedAt: createdAt.Add(-time.Minute), + indexTime: createdAt.Add(time.Minute), + existingMentionRecipients: map[string][]string{post.URI: {mentionRecipientBDID, mentionRecipientDDID}}, + } + intents, err := FanoutPostCreate(context.Background(), lookups, nil, post) + require.NoError(t, err) + var want []Intent + for _, did := range recipients[:MaxMentionsPerRecord-2] { + want = append(want, notificationPostMentionIntent(post, did)) + } + require.Equal(t, len(want), len(intents), "two existing rows leave eight new post mention slots") + require.Equal(t, want, intents, "the existing holder is excluded before filling eight remaining slots") +} diff --git a/internal/core/notifications/fanout_mentions_test.go b/internal/core/notifications/fanout_mentions_test.go new file mode 100644 index 0000000..abddbbf --- /dev/null +++ b/internal/core/notifications/fanout_mentions_test.go @@ -0,0 +1,522 @@ +package notifications + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "Coves/internal/core/richtext" + "github.com/stretchr/testify/require" +) + +const ( + mentionReplyRecipientDID = "did:plc:timegaterecipient" + mentionRecipientBDID = "did:plc:mentionrecipientb" + mentionRecipientDDID = "did:plc:mentionrecipientd" + mentionRecipientMDID = "did:plc:mentionrecipientm" +) + +func notificationMentionComment() CommentRecord { + comment := notificationTimeGateComment(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC)) + comment.FacetsJSON = `[ + {"index":{"byteStart":0,"byteEnd":5},"features":[{"$type":"social.coves.richtext.facet#mention","did":"did:plc:mentionrecipientb"}]}, + {"index":{"byteStart":6,"byteEnd":11},"features":[{"$type":"social.coves.richtext.facet#mention","did":"did:plc:mentionrecipientb"}]}, + {"index":{"byteStart":12,"byteEnd":17},"features":[{"$type":"social.coves.richtext.facet#mention","did":"did:plc:mentionrecipientd"}]} + ]` + return comment +} + +func notificationMentionFacets(recipientDIDs ...string) string { + facets := make([]string, 0, len(recipientDIDs)) + for index, did := range recipientDIDs { + facets = append(facets, fmt.Sprintf(`{"index":{"byteStart":%d,"byteEnd":%d},"features":[{"$type":"social.coves.richtext.facet#mention","did":%q}]}`, + index*6, index*6+5, did)) + } + return "[" + strings.Join(facets, ",") + "]" +} + +// notificationMentionFacetsPerFacet packs featuresPerFacet mention features +// into each facet, all over the same one-byte range: the shape that multiplies +// the facet cap by the per-facet feature cap. +func notificationMentionFacetsPerFacet(featuresPerFacet int, recipientDIDs ...string) string { + var facets []string + for start := 0; start < len(recipientDIDs); start += featuresPerFacet { + end := min(start+featuresPerFacet, len(recipientDIDs)) + features := make([]string, 0, end-start) + for _, did := range recipientDIDs[start:end] { + features = append(features, fmt.Sprintf(`{"$type":"social.coves.richtext.facet#mention","did":%q}`, did)) + } + facets = append(facets, `{"index":{"byteStart":0,"byteEnd":1},"features":[`+strings.Join(features, ",")+`]}`) + } + return "[" + strings.Join(facets, ",") + "]" +} + +func notificationExpectedIntent(comment CommentRecord, reason Reason, recipientDID, subjectURI string) Intent { + return Intent{ + Reason: reason, RecipientDID: recipientDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: subjectURI, + RootPostURI: comment.RootURI, RecordCreatedAt: comment.CreatedAt, + } +} + +func TestFanoutCommentCreate_MentionIntentsFollowReplyInFacetOrder(t *testing.T) { + comment := notificationMentionComment() + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionReplyRecipientDID: true, mentionRecipientBDID: true, mentionRecipientDDID: true, + }, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + { + Reason: ReasonPostReply, RecipientDID: mentionReplyRecipientDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: comment.RootURI, + RootPostURI: comment.RootURI, RecordCreatedAt: comment.CreatedAt, + }, + { + Reason: ReasonMention, RecipientDID: mentionRecipientBDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: "", + RootPostURI: comment.RootURI, RecordCreatedAt: comment.CreatedAt, + }, + { + Reason: ReasonMention, RecipientDID: mentionRecipientDDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: "", + RootPostURI: comment.RootURI, RecordCreatedAt: comment.CreatedAt, + }, + }, intents, "duplicate mention facets notify B only once, and D follows B") +} + +func TestFanoutCommentCreate_MentionCandidatesShareRecordGateAndRecipientLookup(t *testing.T) { + comment := notificationMentionComment() + var activationCalls, indexTimeCalls int + var recipientFactsCalls [][]string + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionReplyRecipientDID: true, mentionRecipientBDID: true, mentionRecipientDDID: true, + }, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + activatedAtCalls: &activationCalls, + indexTimeCalls: &indexTimeCalls, + recipientFactsCalls: &recipientFactsCalls, + } + + _, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, 1, activationCalls, "check activation once for the whole comment") + require.Equal(t, 1, indexTimeCalls, "check freshness once for the whole comment") + require.Equal(t, [][]string{{mentionReplyRecipientDID, mentionRecipientBDID, mentionRecipientDDID}}, recipientFactsCalls, + "read reply and distinct mention recipient facts together, in facet order") +} + +func TestFanoutCommentCreate_MentionMalformedFacetsDoNotSuppressValidMention(t *testing.T) { + comment := notificationMentionComment() + comment.FacetsJSON = `[ + 42, + {"index":{"byteStart":0,"byteEnd":5},"features":[{"$type":"social.coves.richtext.facet#mention","did":17}]}, + {"index":{"byteStart":6,"byteEnd":11},"features":[{"$type":"social.coves.richtext.facet#mention","did":"not-a-did"}]}, + {"index":{"byteStart":12,"byteEnd":17},"features":[{"$type":"social.coves.richtext.facet#mention","did":"did:plc:mentionrecipientd"}]} + ]` + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionReplyRecipientDID: true, mentionRecipientDDID: true, "not-a-did": true, + }, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + { + Reason: ReasonPostReply, RecipientDID: mentionReplyRecipientDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: comment.RootURI, + RootPostURI: comment.RootURI, RecordCreatedAt: comment.CreatedAt, + }, + { + Reason: ReasonMention, RecipientDID: mentionRecipientDDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: "", + RootPostURI: comment.RootURI, RecordCreatedAt: comment.CreatedAt, + }, + }, intents, "malformed entries cannot hide or add a mention") +} + +func TestFanoutCommentCreate_MentionRequiresParseableRootURI(t *testing.T) { + comment := notificationMentionComment() + comment.RootURI = "not-an-at-uri" + comment.ParentURI = comment.RootURI + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionReplyRecipientDID: true, mentionRecipientBDID: true, mentionRecipientDDID: true, + }, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Empty(t, intents, "unparseable root cannot be stored as a mention's root post URI") +} + +// Each root below parses but does not name a post, so it cannot be stored as a +// mention's root_post_uri. +func TestFanoutCommentCreate_MentionRequiresPostRoot(t *testing.T) { + const nonPostRootAuthorDID = "did:plc:mentionnonpostroot" + for _, test := range []struct { + name string + rootURI string + parentURI string + }{ + { + name: "bluesky_post_root_replied_to_directly", + rootURI: "at://" + nonPostRootAuthorDID + "/app.bsky.feed.post/root", + parentURI: "at://" + nonPostRootAuthorDID + "/app.bsky.feed.post/root", + }, + { + name: "comment_root_under_comment_parent", + rootURI: "at://" + nonPostRootAuthorDID + "/social.coves.community.comment/root", + parentURI: "at://" + mentionReplyRecipientDID + "/social.coves.community.comment/parent", + }, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationMentionComment() + comment.RootURI = test.rootURI + comment.ParentURI = test.parentURI + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + nonPostRootAuthorDID: true, mentionReplyRecipientDID: true, mentionRecipientDDID: true, + }, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Empty(t, intents, "a root that is not a post cannot be stored as indexed D's mention root") + }) + } +} + +// PRD "Post author resolution": when a legacy root's posts row is missing, the +// event produces no notification, so D's mention is dropped along with the reply. +func TestFanoutCommentCreate_MissingLegacyPostAuthorSuppressesMentions(t *testing.T) { + const legacyAuthorityDID = "did:plc:legacycommunity" + comment := notificationMentionComment() + comment.RootURI = "at://" + legacyAuthorityDID + "/social.coves.community.post/missing" + comment.ParentURI = comment.RootURI + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{legacyAuthorityDID: true, mentionRecipientDDID: true}, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Empty(t, intents, "a legacy root without a posts row produces no notification, not even indexed D's mention") +} + +// One record may carry richtext.MaxFacets facets of richtext.MaxFeaturesPerFacet +// mentions each. Fan-out keeps only the first MaxFacets distinct mentioned DIDs, +// counted before self and reply-recipient suppression. +func TestFanoutCommentCreate_MentionsCappedAtMaxFacetsDistinctDIDs(t *testing.T) { + const mentionCount = 15 * richtext.MaxFeaturesPerFacet + require.Greater(t, mentionCount, richtext.MaxFacets, "fixture: more distinct mentions than the cap") + cappedMentions := make([]string, 0, mentionCount) + for index := 0; index < mentionCount; index++ { + cappedMentions = append(cappedMentions, fmt.Sprintf("did:plc:cappedmention%03d", index)) + } + for _, test := range []struct { + name string + mentioned func(comment CommentRecord) []string + wantMentions []string + }{ + { + name: "first_distinct_dids_in_facet_order", + mentioned: func(CommentRecord) []string { return cappedMentions }, + wantMentions: cappedMentions[:richtext.MaxFacets], + }, + { + name: "self_and_reply_recipient_count_toward_the_cap", + mentioned: func(comment CommentRecord) []string { + return append([]string{comment.AuthorDID, mentionReplyRecipientDID}, cappedMentions[:mentionCount-2]...) + }, + wantMentions: cappedMentions[:richtext.MaxFacets-2], + }, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationMentionComment() + comment.FacetsJSON = notificationMentionFacetsPerFacet(richtext.MaxFeaturesPerFacet, test.mentioned(comment)...) + indexedUsers := map[string]bool{comment.AuthorDID: true, mentionReplyRecipientDID: true} + for _, did := range cappedMentions { + indexedUsers[did] = true + } + var recipientFactsCalls [][]string + lookups := commentFanoutLookups{ + indexedUsers: indexedUsers, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + recipientFactsCalls: &recipientFactsCalls, + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, 1+MaxMentionsPerRecord, len(intents), "one reply plus ten mention notifications") + want := []Intent{notificationExpectedIntent(comment, ReasonPostReply, mentionReplyRecipientDID, comment.RootURI)} + for _, did := range test.wantMentions[:MaxMentionsPerRecord] { + want = append(want, notificationExpectedIntent(comment, ReasonMention, did, "")) + } + require.Equal(t, want, intents, "only the first ten eligible mentions inside the 200-DID parse bound notify") + require.Equal(t, [][]string{append([]string{mentionReplyRecipientDID}, test.wantMentions...)}, recipientFactsCalls, + "one recipient-facts read with the reply recipient and only the capped mentions") + }) + } +} + +func TestFanoutCommentCreate_MentionOfPostReplyRecipientIsSuppressed(t *testing.T) { + comment := notificationMentionComment() + comment.RootURI = "at://" + mentionRecipientBDID + "/social.coves.community.postv2/post" + comment.ParentURI = comment.RootURI + comment.FacetsJSON = notificationMentionFacets(mentionRecipientBDID, mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientBDID: true, mentionRecipientDDID: true}, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + notificationExpectedIntent(comment, ReasonPostReply, mentionRecipientBDID, comment.RootURI), + notificationExpectedIntent(comment, ReasonMention, mentionRecipientDDID, ""), + }, intents, "post author B receives only the reply; eligible D receives the mention") +} + +// The legacy root's authority is a community, not the author. Suppression keys +// on the author resolved from the posts row, so B gets only the reply. +func TestFanoutCommentCreate_MentionOfLegacyPostReplyRecipientIsSuppressed(t *testing.T) { + const legacyAuthorityDID = "did:plc:legacycommunity" + comment := notificationMentionComment() + comment.RootURI = "at://" + legacyAuthorityDID + "/social.coves.community.post/post" + comment.ParentURI = comment.RootURI + comment.FacetsJSON = notificationMentionFacets(mentionRecipientBDID, mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{legacyAuthorityDID: true, mentionRecipientBDID: true, mentionRecipientDDID: true}, + legacyPostAuthors: map[string]string{comment.RootURI: mentionRecipientBDID}, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + notificationExpectedIntent(comment, ReasonPostReply, mentionRecipientBDID, comment.RootURI), + notificationExpectedIntent(comment, ReasonMention, mentionRecipientDDID, ""), + }, intents, "legacy post author B receives only the reply; eligible D receives the mention") +} + +func TestFanoutCommentCreate_MentionOfCommentReplyRecipientIsSuppressedButRootAuthorIsNot(t *testing.T) { + comment := notificationMentionComment() + comment.RootURI = "at://" + mentionRecipientBDID + "/social.coves.community.postv2/post" + comment.ParentURI = "at://" + mentionReplyRecipientDID + "/social.coves.community.comment/parent" + comment.FacetsJSON = notificationMentionFacets(mentionReplyRecipientDID, mentionRecipientBDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionReplyRecipientDID: true, mentionRecipientBDID: true}, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + notificationExpectedIntent(comment, ReasonCommentReply, mentionReplyRecipientDID, comment.ParentURI), + notificationExpectedIntent(comment, ReasonMention, mentionRecipientBDID, ""), + }, intents, "parent author C receives only the reply; root post author B is a distinct mention") +} + +func TestFanoutCommentCreate_MentionOfSelfIsSuppressed(t *testing.T) { + comment := notificationMentionComment() + comment.FacetsJSON = notificationMentionFacets(comment.AuthorDID, mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + comment.AuthorDID: true, mentionReplyRecipientDID: true, mentionRecipientDDID: true, + }, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + notificationExpectedIntent(comment, ReasonPostReply, mentionReplyRecipientDID, comment.RootURI), + notificationExpectedIntent(comment, ReasonMention, mentionRecipientDDID, ""), + }, intents, "indexed, eligible author A cannot notify itself, but D still gets a mention") +} + +func TestFanoutCommentCreate_MentionOfCommunityIsSuppressed(t *testing.T) { + const communityDID = "did:plc:mentionedcommunity" + comment := notificationMentionComment() + comment.FacetsJSON = notificationMentionFacets(communityDID, mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionReplyRecipientDID: true, communityDID: true, mentionRecipientDDID: true, + }, + communityAccounts: map[string]bool{communityDID: true}, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + notificationExpectedIntent(comment, ReasonPostReply, mentionReplyRecipientDID, comment.RootURI), + notificationExpectedIntent(comment, ReasonMention, mentionRecipientDDID, ""), + }, intents, "an indexed community K does not receive a mention, while D does") +} + +func TestFanoutCommentCreate_MentionSurvivesMissingOrSuppressedReply(t *testing.T) { + const replyRecipientDID = "did:plc:missingreplyrecipient" + const unsupportedParentDID = "did:plc:unsupportedparent" + for _, test := range []struct { + name string + parentURI string + rootURI string + indexed map[string]bool + blocks map[commentFanoutBlock]bool + }{ + { + name: "reply_to_own_post", parentURI: "at://did:plc:timegatecommenter/social.coves.community.postv2/own", + rootURI: "at://did:plc:timegatecommenter/social.coves.community.postv2/own", + indexed: map[string]bool{"did:plc:timegatecommenter": true, mentionRecipientDDID: true}, + }, + { + name: "reply_recipient_not_indexed", parentURI: "at://" + replyRecipientDID + "/social.coves.community.postv2/post", + rootURI: "at://" + replyRecipientDID + "/social.coves.community.postv2/post", + indexed: map[string]bool{mentionRecipientDDID: true}, + }, + { + name: "reply_recipient_blocks_actor", parentURI: "at://" + replyRecipientDID + "/social.coves.community.postv2/post", + rootURI: "at://" + replyRecipientDID + "/social.coves.community.postv2/post", + indexed: map[string]bool{replyRecipientDID: true, mentionRecipientDDID: true}, + blocks: map[commentFanoutBlock]bool{{blockerDID: replyRecipientDID, blockedDID: "did:plc:timegatecommenter"}: true}, + }, + { + name: "unsupported_parent_collection", parentURI: "at://" + unsupportedParentDID + "/app.bsky.feed.post/parent", + rootURI: "at://" + mentionReplyRecipientDID + "/social.coves.community.postv2/post", + indexed: map[string]bool{unsupportedParentDID: true, mentionReplyRecipientDID: true, mentionRecipientDDID: true}, + }, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationMentionComment() + comment.ParentURI = test.parentURI + comment.RootURI = test.rootURI + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: test.indexed, + blocks: test.blocks, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + notificationExpectedIntent(comment, ReasonMention, mentionRecipientDDID, ""), + }, intents, "a missing or suppressed reply must not stop D's mention") + }) + } +} + +func TestFanoutCommentCreate_MentionRecipientEligibility(t *testing.T) { + for _, test := range []struct { + name string + setup func(*commentFanoutLookups, string) + bridgeHosts BridgeHostChecker + }{ + {"missing_users_row", func(lookups *commentFanoutLookups, _ string) { + delete(lookups.indexedUsers, mentionRecipientMDID) + }, nil}, + {"erased", func(lookups *commentFanoutLookups, _ string) { + lookups.erasedAccounts = map[string]bool{mentionRecipientMDID: true} + }, nil}, + {"aggregator", func(lookups *commentFanoutLookups, _ string) { + lookups.aggregatorAccounts = map[string]bool{mentionRecipientMDID: true} + }, nil}, + {"trusted_bridge_host", func(lookups *commentFanoutLookups, _ string) { + lookups.userPDSURLs = map[string]string{mentionRecipientMDID: "https://bridge.test"} + }, ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{"https://bridge.test": true}}}, + {"recipient_blocks_actor", func(lookups *commentFanoutLookups, actorDID string) { + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: mentionRecipientMDID, blockedDID: actorDID}: true} + }, nil}, + {"actor_blocks_recipient", func(lookups *commentFanoutLookups, actorDID string) { + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: actorDID, blockedDID: mentionRecipientMDID}: true} + }, nil}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationMentionComment() + comment.FacetsJSON = notificationMentionFacets(mentionRecipientMDID, mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionReplyRecipientDID: true, mentionRecipientMDID: true, mentionRecipientDDID: true, + }, + activatedAt: comment.CreatedAt.Add(-time.Minute), + indexTime: comment.CreatedAt.Add(time.Minute), + } + test.setup(&lookups, comment.AuthorDID) + + intents, err := FanoutCommentCreate(context.Background(), lookups, test.bridgeHosts, comment) + require.NoError(t, err) + require.Equal(t, []Intent{ + notificationExpectedIntent(comment, ReasonPostReply, mentionReplyRecipientDID, comment.RootURI), + notificationExpectedIntent(comment, ReasonMention, mentionRecipientDDID, ""), + }, intents, "ineligible M must not receive a mention; eligible D must") + }) + } +} + +func TestFanoutCommentCreate_MentionRecordTimeGateBoundaries(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + createdAt time.Time + activatedAt time.Time + indexTime time.Time + wantIntents bool + }{ + {"before_activation", indexTime, indexTime.Add(time.Nanosecond), indexTime.Add(time.Minute), false}, + {"older_than_seven_days", indexTime.Add(-7*24*time.Hour - time.Microsecond), indexTime.Add(-30 * 24 * time.Hour), indexTime, false}, + {"exactly_at_activation", indexTime, indexTime, indexTime.Add(time.Minute), true}, + {"exactly_seven_days_old", indexTime.Add(-7 * 24 * time.Hour), indexTime.Add(-30 * 24 * time.Hour), indexTime, true}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationTimeGateComment(test.createdAt) + comment.FacetsJSON = notificationMentionFacets(mentionRecipientMDID, mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionReplyRecipientDID: true, mentionRecipientMDID: true, mentionRecipientDDID: true, + }, + activatedAt: test.activatedAt, + indexTime: test.indexTime, + } + + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + var want []Intent + if test.wantIntents { + want = []Intent{ + notificationExpectedIntent(comment, ReasonPostReply, mentionReplyRecipientDID, comment.RootURI), + notificationExpectedIntent(comment, ReasonMention, mentionRecipientMDID, ""), + notificationExpectedIntent(comment, ReasonMention, mentionRecipientDDID, ""), + } + } + require.Equal(t, want, intents, "record time gates apply to both reply and all mentions") + }) + } +} diff --git a/internal/core/notifications/fanout_post_edit_test.go b/internal/core/notifications/fanout_post_edit_test.go new file mode 100644 index 0000000..30a8c5c --- /dev/null +++ b/internal/core/notifications/fanout_post_edit_test.go @@ -0,0 +1,265 @@ +package notifications + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFanoutPostEdit_OnlyNewNonAuthorMentions(t *testing.T) { + post := notificationPostWithMentions(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + mentionRecipientBDID, mentionRecipientDDID, postMentionAuthorDID) + post.CID = "bafyreipostedited" + var activationCalls, indexTimeCalls int + var recipientFactsCalls [][]string + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionRecipientBDID: true, mentionRecipientDDID: true, postMentionAuthorDID: true, + }, + activatedAt: post.CreatedAt.Add(-time.Minute), indexTime: post.CreatedAt.Add(time.Minute), + activatedAtCalls: &activationCalls, indexTimeCalls: &indexTimeCalls, + recipientFactsCalls: &recipientFactsCalls, existingMentionRecipientsCalls: &existingCalls, + } + intents, err := FanoutPostEdit(context.Background(), lookups, nil, post, + notificationMentionFacets(mentionRecipientBDID)) + require.NoError(t, err) + require.Equal(t, []Intent{notificationPostMentionIntent(post, mentionRecipientDDID)}, intents, + "only edit-added E receives a mention; stored B and the author do not") + require.Equal(t, 1, activationCalls) + require.Zero(t, indexTimeCalls, "an edit with no event time must not gate on the record's age") + require.Equal(t, []string{post.URI}, existingCalls) + require.Equal(t, [][]string{{mentionRecipientDDID}}, recipientFactsCalls) +} + +func TestFanoutPostEdit_UnsupportedURIHasNoLookups(t *testing.T) { + for _, test := range []struct{ name, uri string }{ + {"legacy post", "at://" + postMentionAuthorDID + "/social.coves.community.post/post"}, + {"unparsable URI", "not-an-at-uri"}, + } { + t.Run(test.name, func(t *testing.T) { + post := notificationPostWithMentions(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), mentionRecipientDDID) + post.URI = test.uri + var activationCalls, indexTimeCalls, legacyCalls, aggregatorCalls int + var recipientFactsCalls [][]string + var existingCalls []string + var earlierCalls []earlierUpvoteCall + lookups := commentFanoutLookups{ + activatedAtCalls: &activationCalls, indexTimeCalls: &indexTimeCalls, + legacyPostAuthorCalls: &legacyCalls, isAggregatorCalls: &aggregatorCalls, + recipientFactsCalls: &recipientFactsCalls, + existingMentionRecipientsCalls: &existingCalls, earlierUpvoteCalls: &earlierCalls, + } + intents, err := FanoutPostEdit(context.Background(), lookups, nil, post, "") + require.NoError(t, err) + require.Nil(t, intents) + require.Zero(t, activationCalls) + require.Zero(t, indexTimeCalls) + require.Zero(t, legacyCalls) + require.Zero(t, aggregatorCalls) + require.Empty(t, recipientFactsCalls) + require.Empty(t, existingCalls) + require.Empty(t, earlierCalls) + }) + } +} + +func TestFanoutPostEdit_NoAddedMentionsMakesNoLookups(t *testing.T) { + for _, test := range []struct { + name, previous string + current []string + }{ + {"unchanged", notificationMentionFacets(mentionRecipientBDID), []string{mentionRecipientBDID}}, + {"only removals", notificationMentionFacets(mentionRecipientBDID, mentionRecipientDDID), []string{mentionRecipientBDID}}, + {"only author added", notificationMentionFacets(mentionRecipientBDID), []string{mentionRecipientBDID, postMentionAuthorDID}}, + } { + t.Run(test.name, func(t *testing.T) { + post := notificationPostWithMentions(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), test.current...) + var activationCalls, indexTimeCalls, legacyCalls, aggregatorCalls int + var recipientFactsCalls [][]string + var existingCalls []string + var earlierCalls []earlierUpvoteCall + lookups := commentFanoutLookups{ + activatedAtCalls: &activationCalls, indexTimeCalls: &indexTimeCalls, + legacyPostAuthorCalls: &legacyCalls, isAggregatorCalls: &aggregatorCalls, + recipientFactsCalls: &recipientFactsCalls, + existingMentionRecipientsCalls: &existingCalls, earlierUpvoteCalls: &earlierCalls, + } + intents, err := FanoutPostEdit(context.Background(), lookups, nil, post, test.previous) + require.NoError(t, err) + require.Nil(t, intents, "keeping or removing mentions must not create or retract rows") + require.Zero(t, activationCalls) + require.Zero(t, indexTimeCalls) + require.Zero(t, legacyCalls) + require.Zero(t, aggregatorCalls) + require.Empty(t, recipientFactsCalls) + require.Empty(t, existingCalls) + require.Empty(t, earlierCalls) + }) + } +} + +func TestFanoutPostEdit_PreActivationStoredPostDoesNotNotify(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientDDID: true}, + activatedAt: indexTime.Add(-time.Hour), indexTime: indexTime, + } + control := notificationPostWithMentions(indexTime.Add(-time.Minute), mentionRecipientDDID) + control.CID = "bafyreiposteditcontrol" + controlIntents, err := FanoutPostEdit(context.Background(), lookups, nil, control, "") + require.NoError(t, err) + require.Equal(t, []Intent{notificationPostMentionIntent(control, mentionRecipientDDID)}, controlIntents, + "fixture: a post created after activation must notify on edit") + + post := notificationPostWithMentions(indexTime.Add(-2*time.Hour), mentionRecipientDDID) + post.CID = "bafyreiposteditpreactivation" + // A fresh event time means a freshness check would read IndexTime. + post.EditEventTime = indexTime + var activationCalls, indexTimeCalls int + var recipientFactsCalls [][]string + var existingCalls []string + lookups.activatedAtCalls, lookups.indexTimeCalls = &activationCalls, &indexTimeCalls + lookups.recipientFactsCalls = &recipientFactsCalls + lookups.existingMentionRecipientsCalls = &existingCalls + intents, err := FanoutPostEdit(context.Background(), lookups, nil, post, "") + require.NoError(t, err) + require.Nil(t, intents, "the stored createdAt predates activation despite the edit happening now") + require.Equal(t, 1, activationCalls) + require.Zero(t, indexTimeCalls, "activation must be checked before edit freshness") + require.Empty(t, existingCalls, "a pre-activation edit must stop before reading the mention budget") + require.Empty(t, recipientFactsCalls) +} + +func TestFanoutPostEdit_ExistingEightMentionsLeaveTwoSlots(t *testing.T) { + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + post := notificationPostWithMentions(createdAt) + post.CID = "bafyreiposteditcapped" + existing := make([]string, MaxMentionsPerRecord-2) + for index := range existing { + existing[index] = fmt.Sprintf("did:plc:posteditexisting%02d", index) + } + added := []string{ + "did:plc:posteditadded01", "did:plc:posteditadded02", + "did:plc:posteditadded03", "did:plc:posteditadded04", + } + post.FacetsJSON = notificationMentionFacets(added...) + indexed := make(map[string]bool, len(added)) + for _, did := range added { + indexed[did] = true + } + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: indexed, activatedAt: createdAt.Add(-time.Minute), indexTime: createdAt.Add(time.Minute), + existingMentionRecipients: map[string][]string{post.URI: existing}, + existingMentionRecipientsCalls: &existingCalls, + } + intents, err := FanoutPostEdit(context.Background(), lookups, nil, post, "") + require.NoError(t, err) + require.Equal(t, []Intent{ + notificationPostMentionIntent(post, added[0]), + notificationPostMentionIntent(post, added[1]), + }, intents, "eight existing mention rows leave exactly two slots, assigned in facet order") + require.Equal(t, []string{post.URI}, existingCalls) +} + +func TestFanoutPostEdit_AllLookupErrorsAreWrapped(t *testing.T) { + for _, test := range []struct { + name string + setError func(*commentFanoutLookups, error) + }{ + {"ReferenceStates", func(lookups *commentFanoutLookups, err error) { lookups.referenceStatesError = err }}, + {"ActivatedAt", func(lookups *commentFanoutLookups, err error) { lookups.activatedAtError = err }}, + {"RecipientFacts", func(lookups *commentFanoutLookups, err error) { lookups.recipientFactsError = err }}, + {"ExistingMentionRecipients", func(lookups *commentFanoutLookups, err error) { + lookups.existingMentionRecipientsError = err + }}, + } { + t.Run(test.name, func(t *testing.T) { + sentinel := errors.New("post edit lookup failed") + post := notificationPostWithMentions(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), mentionRecipientDDID) + post.CID = "bafyreipostediterror" + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientDDID: true}, + activatedAt: post.CreatedAt.Add(-time.Minute), indexTime: post.CreatedAt.Add(time.Minute), + } + test.setError(&lookups, sentinel) + intents, err := FanoutPostEdit(context.Background(), lookups, nil, post, "") + require.ErrorIs(t, err, sentinel, "a failing lookup must abort post edit fan-out") + if test.name == "ReferenceStates" { + require.ErrorContains(t, err, "look up notification reference states") + } + require.ErrorContains(t, err, "look up notification", "wrap the lookup error with notification context") + require.Empty(t, intents, "a failed lookup must not return partial intents") + }) + } +} + +func TestFanoutPostEdit_EventTimeFreshnessBoundary(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + eventTime time.Time + wantMention bool + wantIndexCall int + }{ + {"older than seven days", indexTime.Add(-7*24*time.Hour - time.Second), false, 1}, + {"exactly seven days", indexTime.Add(-7 * 24 * time.Hour), true, 1}, + {"inside seven days", indexTime.Add(-7*24*time.Hour + time.Second), true, 1}, + {"no event time on a thirty-day-old post", time.Time{}, true, 0}, + } { + t.Run(test.name, func(t *testing.T) { + post := notificationPostWithMentions(indexTime.Add(-30*24*time.Hour), mentionRecipientDDID) + post.CID = "bafyreipostediteventtime" + post.EditEventTime = test.eventTime + var indexCalls int + var recipientFactsCalls [][]string + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientDDID: true}, + activatedAt: indexTime.Add(-40 * 24 * time.Hour), indexTime: indexTime, + indexTimeCalls: &indexCalls, recipientFactsCalls: &recipientFactsCalls, + existingMentionRecipientsCalls: &existingCalls, + } + if test.eventTime.IsZero() { + lookups.indexTimeError = errors.New("no event time must not require IndexTime") + } + intents, err := FanoutPostEdit(context.Background(), lookups, nil, post, "") + require.NoError(t, err) + if test.wantMention { + require.Equal(t, []Intent{notificationPostMentionIntent(post, mentionRecipientDDID)}, intents, + "a qualifying edit notifies despite the stored post being thirty days old") + require.Equal(t, [][]string{{mentionRecipientDDID}}, recipientFactsCalls) + } else { + require.Nil(t, intents, "an edit event older than seven days must not notify") + require.Empty(t, recipientFactsCalls, "stale edits must stop before reading recipient facts") + require.Empty(t, existingCalls, "stale edits must stop before reading the mention budget") + } + require.Equal(t, test.wantIndexCall, indexCalls) + }) + } +} + +func TestFanoutPostEdit_EventTimeIndexLookupErrorIsWrapped(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + post := notificationPostWithMentions(indexTime.Add(-30*24*time.Hour), mentionRecipientDDID) + post.EditEventTime = indexTime.Add(-time.Hour) + sentinel := errors.New("edit index time lookup failed") + var indexCalls int + var recipientFactsCalls [][]string + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientDDID: true}, + activatedAt: indexTime.Add(-40 * 24 * time.Hour), indexTime: indexTime, + indexTimeError: sentinel, indexTimeCalls: &indexCalls, recipientFactsCalls: &recipientFactsCalls, + } + intents, err := FanoutPostEdit(context.Background(), lookups, nil, post, "") + require.ErrorIs(t, err, sentinel) + require.ErrorContains(t, err, "look up notification") + require.Empty(t, intents) + require.Equal(t, 1, indexCalls) + require.Empty(t, recipientFactsCalls, "failed freshness lookup must not read recipient facts") +} diff --git a/internal/core/notifications/fanout_post_mentions_test.go b/internal/core/notifications/fanout_post_mentions_test.go new file mode 100644 index 0000000..7b96eaf --- /dev/null +++ b/internal/core/notifications/fanout_post_mentions_test.go @@ -0,0 +1,197 @@ +package notifications + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const postMentionAuthorDID = "did:plc:postmentionauthor" + +func notificationPostWithMentions(createdAt time.Time, recipientDIDs ...string) PostRecord { + return PostRecord{ + URI: "at://" + postMentionAuthorDID + "/social.coves.community.postv2/post", + CID: "bafyreipostmentions", AuthorDID: postMentionAuthorDID, + CreatedAt: createdAt, FacetsJSON: notificationMentionFacets(recipientDIDs...), + } +} + +func notificationPostMentionIntent(post PostRecord, recipientDID string) Intent { + return Intent{ + Reason: ReasonMention, RecipientDID: recipientDID, ActorDID: post.AuthorDID, + RecordURI: post.URI, RecordCID: post.CID, SubjectURI: "", + RootPostURI: post.URI, RecordCreatedAt: post.CreatedAt, + } +} + +func TestFanoutPostCreate_MentionsEligibleUsersOnceInFacetOrder(t *testing.T) { + const communityDID = "did:plc:postmentioncommunity" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + post := notificationPostWithMentions(createdAt, + mentionRecipientBDID, mentionRecipientDDID, communityDID, postMentionAuthorDID, mentionRecipientBDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + mentionRecipientBDID: true, mentionRecipientDDID: true, communityDID: true, postMentionAuthorDID: true, + }, + communityAccounts: map[string]bool{communityDID: true}, + activatedAt: createdAt.Add(-time.Minute), + indexTime: createdAt.Add(time.Minute), + } + + intents, err := FanoutPostCreate(context.Background(), lookups, nil, post) + require.NoError(t, err) + require.Len(t, intents, 2, "B and D must each receive a post mention") + require.Equal(t, []Intent{ + notificationPostMentionIntent(post, mentionRecipientBDID), + notificationPostMentionIntent(post, mentionRecipientDDID), + }, intents, "B and D each receive one mention in facet order; a community and the author do not") +} + +func TestFanoutPostCreate_RejectsUnparsableAndLegacyPostURIsWithoutLookups(t *testing.T) { + for _, test := range []struct { + name string + uri string + }{ + {"unparsable_URI", "not-an-at-uri"}, + {"legacy_post", "at://" + postMentionAuthorDID + "/social.coves.community.post/post"}, + } { + t.Run(test.name, func(t *testing.T) { + post := notificationPostWithMentions(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), mentionRecipientBDID) + post.URI = test.uri + var activationCalls, indexTimeCalls, legacyPostAuthorCalls int + var recipientFactsCalls [][]string + lookupError := errors.New("unexpected lookup for an unsupported post URI") + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientBDID: true}, + activatedAtError: lookupError, + indexTimeError: lookupError, + recipientFactsError: lookupError, + legacyPostAuthorError: lookupError, + activatedAtCalls: &activationCalls, + indexTimeCalls: &indexTimeCalls, + recipientFactsCalls: &recipientFactsCalls, + legacyPostAuthorCalls: &legacyPostAuthorCalls, + } + intents, err := FanoutPostCreate(context.Background(), lookups, nil, post) + require.NoError(t, err) + require.Nil(t, intents) + require.Zero(t, activationCalls) + require.Zero(t, indexTimeCalls) + require.Zero(t, legacyPostAuthorCalls) + require.Empty(t, recipientFactsCalls) + }) + } +} + +func TestFanoutPostCreate_MentionRecordTimeGates(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + createdAt time.Time + activatedAt time.Time + }{ + {"before_activation", indexTime.Add(-2 * time.Hour), indexTime.Add(-time.Hour)}, + {"older_than_seven_days", indexTime.Add(-7*24*time.Hour - time.Microsecond), indexTime.Add(-30 * 24 * time.Hour)}, + } { + t.Run(test.name, func(t *testing.T) { + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientBDID: true}, + activatedAt: test.activatedAt, + indexTime: indexTime, + } + control := notificationPostWithMentions(indexTime.Add(-time.Minute), mentionRecipientBDID) + control.URI += "control" + controlIntents, err := FanoutPostCreate(context.Background(), lookups, nil, control) + require.NoError(t, err) + require.Len(t, controlIntents, 1, "the eligible in-window control post must notify B") + require.Equal(t, []Intent{notificationPostMentionIntent(control, mentionRecipientBDID)}, controlIntents, + "the eligible in-window control post must still notify B") + + post := notificationPostWithMentions(test.createdAt, mentionRecipientBDID) + intents, err := FanoutPostCreate(context.Background(), lookups, nil, post) + require.NoError(t, err) + require.Nil(t, intents, "a post outside the activation or seven-day freshness window must not notify B") + }) + } +} + +func TestFanoutPostCreate_MentionRecipientEligibility(t *testing.T) { + const bridgePDSURL = "https://bridge.test" + for _, test := range []struct { + name string + setup func(*commentFanoutLookups) + bridgeHosts BridgeHostChecker + }{ + {"unindexed", func(lookups *commentFanoutLookups) { + delete(lookups.indexedUsers, mentionRecipientMDID) + }, nil}, + {"erased", func(lookups *commentFanoutLookups) { + lookups.erasedAccounts = map[string]bool{mentionRecipientMDID: true} + }, nil}, + {"aggregator", func(lookups *commentFanoutLookups) { + lookups.aggregatorAccounts = map[string]bool{mentionRecipientMDID: true} + }, nil}, + {"trusted_bridge_PDS", func(lookups *commentFanoutLookups) { + lookups.userPDSURLs = map[string]string{mentionRecipientMDID: bridgePDSURL} + }, ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{bridgePDSURL: true}}}, + {"recipient_blocks_author", func(lookups *commentFanoutLookups) { + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: mentionRecipientMDID, blockedDID: postMentionAuthorDID}: true} + }, nil}, + {"author_blocks_recipient", func(lookups *commentFanoutLookups) { + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: postMentionAuthorDID, blockedDID: mentionRecipientMDID}: true} + }, nil}, + } { + t.Run(test.name, func(t *testing.T) { + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + post := notificationPostWithMentions(createdAt, mentionRecipientMDID, mentionRecipientDDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientMDID: true, mentionRecipientDDID: true}, + activatedAt: createdAt.Add(-time.Minute), + indexTime: createdAt.Add(time.Minute), + } + test.setup(&lookups) + intents, err := FanoutPostCreate(context.Background(), lookups, test.bridgeHosts, post) + require.NoError(t, err) + require.Len(t, intents, 1, "eligible control D must receive a mention") + require.Equal(t, []Intent{notificationPostMentionIntent(post, mentionRecipientDDID)}, intents, + "ineligible M receives no mention while eligible D does") + }) + } +} + +func TestFanoutPostCreate_AllLookupErrorsAreWrapped(t *testing.T) { + for _, test := range []struct { + name string + setError func(*commentFanoutLookups, error) + }{ + {"ReferenceStates", func(lookups *commentFanoutLookups, err error) { lookups.referenceStatesError = err }}, + {"ActivatedAt", func(lookups *commentFanoutLookups, err error) { lookups.activatedAtError = err }}, + {"IndexTime", func(lookups *commentFanoutLookups, err error) { lookups.indexTimeError = err }}, + {"RecipientFacts", func(lookups *commentFanoutLookups, err error) { lookups.recipientFactsError = err }}, + {"ExistingMentionRecipients", func(lookups *commentFanoutLookups, err error) { + lookups.existingMentionRecipientsError = err + }}, + } { + t.Run(test.name, func(t *testing.T) { + sentinel := errors.New("post mention lookup failed") + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + post := notificationPostWithMentions(createdAt, mentionRecipientBDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientBDID: true}, + activatedAt: createdAt.Add(-time.Minute), + indexTime: createdAt.Add(time.Minute), + } + test.setError(&lookups, sentinel) + intents, err := FanoutPostCreate(context.Background(), lookups, nil, post) + require.ErrorIs(t, err, sentinel, "a failing lookup must abort post mention fan-out") + if test.name == "ReferenceStates" { + require.ErrorContains(t, err, "look up notification reference states") + } + require.NotEqual(t, sentinel, err, "the lookup error must be wrapped with context") + require.Empty(t, intents, "a failed lookup must not produce partial intents") + }) + } +} diff --git a/internal/core/notifications/fanout_recipient_gates_test.go b/internal/core/notifications/fanout_recipient_gates_test.go new file mode 100644 index 0000000..78c2f19 --- /dev/null +++ b/internal/core/notifications/fanout_recipient_gates_test.go @@ -0,0 +1,116 @@ +package notifications + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type notificationTestBridgeHosts struct { + trustedURLs map[string]bool + checkedURLs []string +} + +func (checker *notificationTestBridgeHosts) TrustsPDS(pdsURL string) bool { + // BridgeHostChecker requires a nil receiver to trust no host. + if checker == nil { + return false + } + checker.checkedURLs = append(checker.checkedURLs, pdsURL) + return checker.trustedURLs[pdsURL] +} + +func TestFanoutCommentCreate_RecipientEligibility(t *testing.T) { + const recipientDID = "did:plc:timegaterecipient" + const recipientPDSURL = "https://bridge.test" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + setup func(*commentFanoutLookups) + bridgeHosts BridgeHostChecker + checkTrusted bool + }{ + {"erased_recipient", func(lookups *commentFanoutLookups) { + lookups.erasedAccounts = map[string]bool{recipientDID: true} + }, nil, false}, + {"aggregator_recipient", func(lookups *commentFanoutLookups) { + lookups.aggregatorAccounts = map[string]bool{recipientDID: true} + }, nil, false}, + {"trusted_bridge_recipient", func(lookups *commentFanoutLookups) { + lookups.userPDSURLs = map[string]string{recipientDID: recipientPDSURL} + }, ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{recipientPDSURL: true}}, true}, + } { + t.Run(test.name, func(t *testing.T) { + lookups := commentFanoutLookups{indexedUsers: map[string]bool{recipientDID: true}} + test.setup(&lookups) + comment := notificationTimeGateComment(createdAt) + intents, err := FanoutCommentCreate(context.Background(), lookups, test.bridgeHosts, comment) + require.NoError(t, err) + require.Empty(t, intents, "an ineligible recipient must not receive a postReply") + if test.checkTrusted { + checker, ok := test.bridgeHosts.(*notificationTestBridgeHosts) + require.True(t, ok, "the trusted-bridge case needs the recording checker") + require.Equal(t, []string{recipientPDSURL}, checker.checkedURLs, + "bridge trust must be checked against the recipient's users.pds_url") + } + }) + } +} + +func TestFanoutCommentCreate_UntrustedOrNilBridgeCheckerAllowsRecipient(t *testing.T) { + const recipientDID = "did:plc:timegaterecipient" + const recipientPDSURL = "https://native.pds.test" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + bridgeHosts BridgeHostChecker + }{ + {"untrusted_PDS", ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{"https://bridge.test": true}}}, + {"nil_checker", nil}, + } { + t.Run(test.name, func(t *testing.T) { + comment := notificationTimeGateComment(createdAt) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{recipientDID: true}, + userPDSURLs: map[string]string{recipientDID: recipientPDSURL}, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, test.bridgeHosts, comment) + require.NoError(t, err) + require.Equal(t, []Intent{{ + Reason: ReasonPostReply, RecipientDID: recipientDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: comment.RootURI, + RootPostURI: comment.RootURI, RecordCreatedAt: createdAt, + }}, intents, "an untrusted or unchecked PDS must not suppress the reply") + }) + } +} + +// Only the recipient's hosting suppresses. Replies from users on a trusted +// bridge PDS to users on a native PDS are the bridge's main traffic. +func TestFanoutCommentCreate_BridgeHostedActorStillNotifiesNativeRecipient(t *testing.T) { + const actorDID = "did:plc:timegatecommenter" + const recipientDID = "did:plc:timegaterecipient" + const bridgePDSURL = "https://bridge.test" + const nativePDSURL = "https://native.pds.test" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + comment := notificationTimeGateComment(createdAt) + require.Equal(t, actorDID, comment.AuthorDID) + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{actorDID: true, recipientDID: true}, + userPDSURLs: map[string]string{actorDID: bridgePDSURL, recipientDID: nativePDSURL}, + } + bridgeHosts := ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{bridgePDSURL: true}} + // The actor is indexed on a host the checker trusts, so a rule that also + // checked the actor's hosting would suppress this reply. + require.True(t, bridgeHosts.trustedURLs[lookups.userPDSURLs[actorDID]], "the actor must be on a trusted bridge PDS") + + intents, err := FanoutCommentCreate(context.Background(), lookups, bridgeHosts, comment) + require.NoError(t, err) + require.Equal(t, []Intent{{ + Reason: ReasonPostReply, RecipientDID: recipientDID, ActorDID: actorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: comment.RootURI, + RootPostURI: comment.RootURI, RecordCreatedAt: createdAt, + }}, intents, "a bridge-hosted actor must not suppress a reply to a native-PDS recipient") +} diff --git a/internal/core/notifications/fanout_reference_gate_test.go b/internal/core/notifications/fanout_reference_gate_test.go new file mode 100644 index 0000000..b79a28d --- /dev/null +++ b/internal/core/notifications/fanout_reference_gate_test.go @@ -0,0 +1,195 @@ +package notifications + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFanoutComment_WithdrawnReferences(t *testing.T) { + const ( + root = "at://did:plc:referencepostauthor/social.coves.community.postv2/root" + parentComment = "at://did:plc:referencecommentauthor/social.coves.community.comment/parent" + distinctPost = "at://did:plc:referenceotherauthor/social.coves.community.postv2/other" + mention = "did:plc:referencemention" + actor = "did:plc:referenceactor" + postAuthor = "did:plc:referencepostauthor" + commentAuthor = "did:plc:referencecommentauthor" + ownComment = "at://" + actor + "/social.coves.community.comment/reply" + ) + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name, parent string + states map[string]ReferenceState + wantReply Reason + wantRecipient, wantSubject string + }{ + {"top_level_live", root, nil, ReasonPostReply, postAuthor, root}, + {"root_deleted", root, map[string]ReferenceState{root: ReferenceDeleted}, "", "", ""}, + {"root_removed", root, map[string]ReferenceState{root: ReferenceRemovedByModerator}, "", "", ""}, + {"nested_live", parentComment, nil, ReasonCommentReply, commentAuthor, parentComment}, + {"parent_comment_deleted", parentComment, map[string]ReferenceState{parentComment: ReferenceDeleted}, "", "", ""}, + {"distinct_parent_live", distinctPost, nil, "", "", ""}, + {"distinct_parent_deleted", distinctPost, map[string]ReferenceState{distinctPost: ReferenceDeleted}, "", "", ""}, + {"distinct_parent_removed", distinctPost, map[string]ReferenceState{distinctPost: ReferenceRemovedByModerator}, "", "", ""}, + {"own_record_removed_by_server_admin", root, map[string]ReferenceState{ownComment: ReferenceRemovedByServerAdmin}, "", "", ""}, + {"nested_own_record_removed_by_server_admin", parentComment, map[string]ReferenceState{ownComment: ReferenceRemovedByServerAdmin}, "", "", ""}, + } { + for _, operation := range []string{"create", "edit"} { + t.Run(operation+"/"+test.name, func(t *testing.T) { + comment := CommentRecord{ + URI: ownComment, CID: "bafyreferencecomment", + AuthorDID: actor, RootURI: root, ParentURI: test.parent, CreatedAt: createdAt, + FacetsJSON: notificationMentionFacets(mention), EditEventTime: createdAt, + } + var activationCalls, indexCalls int + var recipientCalls [][]string + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{postAuthor: true, commentAuthor: true, mention: true}, + referenceStates: test.states, + activatedAt: createdAt.Add(-time.Minute), indexTime: createdAt.Add(time.Minute), + activatedAtCalls: &activationCalls, indexTimeCalls: &indexCalls, + recipientFactsCalls: &recipientCalls, existingMentionRecipientsCalls: &existingCalls, + } + var intents []Intent + var err error + if operation == "create" { + intents, err = FanoutCommentCreate(context.Background(), lookups, nil, comment) + } else { + intents, err = FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + } + require.NoError(t, err) + if len(test.states) > 0 { + require.Empty(t, intents, "withdrawn root or parent cannot generate notifications") + require.Zero(t, activationCalls) + require.Zero(t, indexCalls) + require.Empty(t, recipientCalls) + require.Empty(t, existingCalls) + return + } + want := []Intent{} + if operation == "create" && test.wantReply != "" { + want = append(want, Intent{Reason: test.wantReply, RecipientDID: test.wantRecipient, + ActorDID: actor, RecordURI: comment.URI, RecordCID: comment.CID, + SubjectURI: test.wantSubject, RootPostURI: root, RecordCreatedAt: createdAt}) + } + want = append(want, Intent{Reason: ReasonMention, RecipientDID: mention, ActorDID: actor, + RecordURI: comment.URI, RecordCID: comment.CID, RootPostURI: root, RecordCreatedAt: createdAt}) + require.Equal(t, want, intents) + }) + } + } +} + +func TestFanoutComment_DistinctParentReferencesLookedUpTogether(t *testing.T) { + comment := notificationEditComment() + comment.ParentURI = "at://did:plc:referenceotherauthor/social.coves.community.postv2/other" + comment.FacetsJSON = notificationMentionFacets(mentionRecipientDDID) + for _, operation := range []string{"create", "edit"} { + t.Run(operation, func(t *testing.T) { + var calls [][]string + lookups := notificationEditLookups(comment) + lookups.referenceStatesCalls = &calls + var intents []Intent + var err error + if operation == "create" { + intents, err = FanoutCommentCreate(context.Background(), lookups, nil, comment) + } else { + intents, err = FanoutCommentEdit(context.Background(), lookups, nil, comment, "") + } + require.NoError(t, err) + require.Equal(t, []Intent{notificationExpectedIntent(comment, ReasonMention, mentionRecipientDDID, "")}, intents) + require.Len(t, calls, 1, "one reference lookup for the comment, its root and distinct parent") + require.ElementsMatch(t, []string{comment.URI, comment.RootURI, comment.ParentURI}, calls[0]) + }) + } +} + +func TestFanoutPost_WithdrawnReferences(t *testing.T) { + for _, operation := range []string{"create", "edit"} { + for _, state := range []struct { + name string + value ReferenceState + }{ + {"live", ReferenceLive}, {"deleted", ReferenceDeleted}, {"removed", ReferenceRemovedByModerator}, + } { + t.Run(operation+"/"+state.name, func(t *testing.T) { + post := notificationPostWithMentions(time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), mentionRecipientDDID) + post.EditEventTime = post.CreatedAt + var activationCalls, indexCalls int + var recipientCalls [][]string + var existingCalls []string + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{mentionRecipientDDID: true}, + referenceStates: map[string]ReferenceState{post.URI: state.value}, + activatedAt: post.CreatedAt.Add(-time.Minute), indexTime: post.CreatedAt.Add(time.Minute), + activatedAtCalls: &activationCalls, indexTimeCalls: &indexCalls, + recipientFactsCalls: &recipientCalls, existingMentionRecipientsCalls: &existingCalls, + } + var intents []Intent + var err error + if operation == "create" { + intents, err = FanoutPostCreate(context.Background(), lookups, nil, post) + } else { + intents, err = FanoutPostEdit(context.Background(), lookups, nil, post, "") + } + require.NoError(t, err) + if state.value != ReferenceLive { + require.Empty(t, intents) + require.Zero(t, activationCalls) + require.Zero(t, indexCalls) + require.Empty(t, recipientCalls) + require.Empty(t, existingCalls) + return + } + require.Equal(t, []Intent{notificationPostMentionIntent(post, mentionRecipientDDID)}, intents) + }) + } + } +} + +func TestFanoutVoteCreate_WithdrawnReferences(t *testing.T) { + for _, test := range []struct { + name, subject, root, withdrawn string + state ReferenceState + wantRecipient string + }{ + {"post_live", voteFanoutPost, "", "", ReferenceLive, voteFanoutAuthor}, + {"post_removed", voteFanoutPost, "", voteFanoutPost, ReferenceRemovedByModerator, voteFanoutAuthor}, + {"post_deleted", voteFanoutPost, "", voteFanoutPost, ReferenceDeleted, voteFanoutAuthor}, + {"comment_live", voteFanoutCommenterComment, voteFanoutPost, "", ReferenceLive, voteFanoutCommenter}, + {"comment_deleted", voteFanoutCommenterComment, voteFanoutPost, voteFanoutCommenterComment, ReferenceDeleted, voteFanoutCommenter}, + {"comment_root_deleted", voteFanoutCommenterComment, voteFanoutPost, voteFanoutPost, ReferenceDeleted, voteFanoutCommenter}, + {"comment_root_removed", voteFanoutCommenterComment, voteFanoutPost, voteFanoutPost, ReferenceRemovedByModerator, voteFanoutCommenter}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + lookups.indexedUsers[voteFanoutCommenter] = true + vote.SubjectURI, vote.SubjectRootURI = test.subject, test.root + if test.withdrawn != "" { + lookups.referenceStates = map[string]ReferenceState{test.withdrawn: test.state} + } + var activationCalls, indexCalls int + lookups.activatedAtCalls, lookups.indexTimeCalls = &activationCalls, &indexCalls + intent, err := FanoutVoteCreate(context.Background(), lookups, nil, vote) + require.NoError(t, err) + if test.withdrawn != "" { + require.Equal(t, UpvoteGroupIntent{Action: UpvoteGroupDeleteIfEmpty, + RecipientDID: test.wantRecipient, SubjectURI: test.subject}, intent) + require.NotEqual(t, UpvoteGroupBump, intent.Action) + require.Zero(t, activationCalls) + require.Zero(t, indexCalls) + return + } + root := test.root + if root == "" { + root = test.subject + } + require.Equal(t, UpvoteGroupIntent{Action: UpvoteGroupBump, + RecipientDID: test.wantRecipient, SubjectURI: test.subject, RootPostURI: root}, intent) + }) + } +} diff --git a/internal/core/notifications/fanout_test.go b/internal/core/notifications/fanout_test.go new file mode 100644 index 0000000..be35c39 --- /dev/null +++ b/internal/core/notifications/fanout_test.go @@ -0,0 +1,475 @@ +package notifications + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// commentFanoutLookups is the T0 Lookups fake. Its zero value never suppresses +// through a gate a test did not configure: +// - unset activatedAt and indexTime are the zero time.Time, which leaves both +// time gates open for every createdAt, including a zero one; tests of the +// activation or seven-day gate set activatedAt and indexTime explicitly; +// - an indexed user with no userPDSURLs entry is on a native, untrusted PDS. +type commentFanoutLookups struct { + referenceStates map[string]ReferenceState + referenceStatesError error + referenceStatesCalls *[][]string + indexedUsers map[string]bool + legacyPostAuthors map[string]string + legacyPostAuthorError error + legacyPostAuthorCalls *int + activatedAt time.Time + indexTime time.Time + activatedAtError error + indexTimeError error + activatedAtCalls *int + indexTimeCalls *int + erasedAccounts map[string]bool + aggregatorAccounts map[string]bool + isAggregatorError error + isAggregatorCalls *int + communityAccounts map[string]bool + userPDSURLs map[string]string + blocks map[commentFanoutBlock]bool + recipientFactsError error + recipientFactsCalls *[][]string + recipientFactsActors *[]string + existingMentionRecipients map[string][]string + existingMentionRecipientsError error + existingMentionRecipientsCalls *[]string + earlierUpvoteExists bool + earlierUpvoteError error + earlierUpvoteCalls *[]earlierUpvoteCall +} + +func (lookup commentFanoutLookups) ReferenceStates(_ context.Context, uris []string) (map[string]ReferenceState, error) { + if lookup.referenceStatesCalls != nil { + *lookup.referenceStatesCalls = append(*lookup.referenceStatesCalls, append([]string{}, uris...)) + } + if lookup.referenceStatesError != nil { + return nil, lookup.referenceStatesError + } + return lookup.referenceStates, nil +} + +type earlierUpvoteCall struct { + voterDID string + subjectURI string + voteURI string +} + +type commentFanoutBlock struct { + blockerDID string + blockedDID string +} + +func (lookup commentFanoutLookups) LegacyPostAuthor(_ context.Context, postURI string) (string, bool, error) { + if lookup.legacyPostAuthorCalls != nil { + (*lookup.legacyPostAuthorCalls)++ + } + if lookup.legacyPostAuthorError != nil { + return "", false, lookup.legacyPostAuthorError + } + authorDID, found := lookup.legacyPostAuthors[postURI] + return authorDID, found, nil +} + +func (lookup commentFanoutLookups) ActivatedAt(_ context.Context) (time.Time, error) { + if lookup.activatedAtCalls != nil { + (*lookup.activatedAtCalls)++ + } + if lookup.activatedAtError != nil { + return time.Time{}, lookup.activatedAtError + } + return lookup.activatedAt, nil +} + +func (lookup commentFanoutLookups) IndexTime(_ context.Context) (time.Time, error) { + if lookup.indexTimeCalls != nil { + (*lookup.indexTimeCalls)++ + } + if lookup.indexTimeError != nil { + return time.Time{}, lookup.indexTimeError + } + return lookup.indexTime, nil +} + +func (lookup commentFanoutLookups) IsAggregator(_ context.Context, did string) (bool, error) { + if lookup.isAggregatorCalls != nil { + (*lookup.isAggregatorCalls)++ + } + if lookup.isAggregatorError != nil { + return false, lookup.isAggregatorError + } + return lookup.aggregatorAccounts[did], nil +} + +func (lookup commentFanoutLookups) EarlierUpvoteExists(_ context.Context, voterDID, subjectURI, voteURI string) (bool, error) { + if lookup.earlierUpvoteCalls != nil { + *lookup.earlierUpvoteCalls = append(*lookup.earlierUpvoteCalls, earlierUpvoteCall{voterDID, subjectURI, voteURI}) + } + if lookup.earlierUpvoteError != nil { + return false, lookup.earlierUpvoteError + } + return lookup.earlierUpvoteExists, nil +} + +func (lookup commentFanoutLookups) RecipientFacts(_ context.Context, actorDID string, recipientDIDs []string) (map[string]RecipientFacts, error) { + if lookup.recipientFactsActors != nil { + *lookup.recipientFactsActors = append(*lookup.recipientFactsActors, actorDID) + } + if lookup.recipientFactsCalls != nil { + *lookup.recipientFactsCalls = append(*lookup.recipientFactsCalls, append([]string{}, recipientDIDs...)) + } + if lookup.recipientFactsError != nil { + return nil, lookup.recipientFactsError + } + facts := make(map[string]RecipientFacts) + for _, did := range recipientDIDs { + if !lookup.indexedUsers[did] { + continue + } + pdsURL, ok := lookup.userPDSURLs[did] + if !ok { + pdsURL = "https://native.pds.test" + } + facts[did] = RecipientFacts{ + PDSURL: pdsURL, + Erased: lookup.erasedAccounts[did], + Aggregator: lookup.aggregatorAccounts[did], + Community: lookup.communityAccounts[did], + BlockedWithActor: lookup.blocks[commentFanoutBlock{blockerDID: actorDID, blockedDID: did}] || + lookup.blocks[commentFanoutBlock{blockerDID: did, blockedDID: actorDID}], + } + } + return facts, nil +} + +func (lookup commentFanoutLookups) ExistingMentionRecipients(_ context.Context, recordURI string) ([]string, error) { + if lookup.existingMentionRecipientsCalls != nil { + *lookup.existingMentionRecipientsCalls = append(*lookup.existingMentionRecipientsCalls, recordURI) + } + if lookup.existingMentionRecipientsError != nil { + return nil, lookup.existingMentionRecipientsError + } + return lookup.existingMentionRecipients[recordURI], nil +} + +// A negative test that leaves the gate times unset must not pass because a +// hidden default time gate suppressed its intent. +func TestCommentFanoutLookups_DefaultTimeGatesNeverSuppress(t *testing.T) { + const ( + commenterDID = "did:plc:defaultgatecommenter" + postAuthorDID = "did:plc:defaultgatepostauthor" + ) + postURI := "at://" + postAuthorDID + "/social.coves.community.postv2/post" + lookups := commentFanoutLookups{indexedUsers: map[string]bool{postAuthorDID: true}} + for _, test := range []struct { + name string + createdAt time.Time + }{ + {"zero_createdAt", time.Time{}}, + {"old_createdAt", time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {"far_future_createdAt", time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC)}, + } { + t.Run(test.name, func(t *testing.T) { + comment := CommentRecord{ + URI: "at://" + commenterDID + "/social.coves.community.comment/reply", CID: "bafyreply", + AuthorDID: commenterDID, ParentURI: postURI, RootURI: postURI, CreatedAt: test.createdAt, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{{ + Reason: ReasonPostReply, RecipientDID: postAuthorDID, ActorDID: commenterDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: postURI, + RootPostURI: postURI, RecordCreatedAt: test.createdAt, + }}, intents, "the fake's unset activation and index times must leave both time gates open") + }) + } +} + +func TestFanoutCommentCreate_PositiveReplyIntents(t *testing.T) { + const ( + commenterDID = "did:plc:commenter" + postAuthorDID = "did:plc:postauthor" + legacyAuthorDID = "did:plc:legacyauthor" + parentAuthorDID = "did:plc:parentauthor" + rootAuthorDID = "did:plc:rootauthor" + ) + postURI := "at://" + postAuthorDID + "/social.coves.community.postv2/postkey" + legacyPostURI := "at://did:plc:community/social.coves.community.post/legacykey" + rootURI := "at://" + rootAuthorDID + "/social.coves.community.postv2/rootkey" + parentURI := "at://" + parentAuthorDID + "/social.coves.community.comment/parentkey" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + // Every would-be recipient is indexed, so an extra intent to the nested + // reply's distinct root author would survive RecipientFacts and be counted. + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{ + postAuthorDID: true, legacyAuthorDID: true, parentAuthorDID: true, rootAuthorDID: true, + }, + legacyPostAuthors: map[string]string{legacyPostURI: legacyAuthorDID}, + } + + for _, test := range []struct { + name string + comment CommentRecord + wantReason Reason + wantDID string + wantURI string + }{ + { + name: "postv2 post reply goes to indexed post author", + comment: CommentRecord{ + URI: "at://" + commenterDID + "/social.coves.community.comment/top", + CID: "bafycommenttop", AuthorDID: commenterDID, + ParentURI: postURI, RootURI: postURI, CreatedAt: createdAt, + }, + wantReason: ReasonPostReply, wantDID: postAuthorDID, wantURI: postURI, + }, + { + name: "legacy post reply goes to indexed row author rather than community", + comment: CommentRecord{ + URI: "at://" + commenterDID + "/social.coves.community.comment/legacy", + CID: "bafycommentlegacy", AuthorDID: commenterDID, + ParentURI: legacyPostURI, RootURI: legacyPostURI, CreatedAt: createdAt, + }, + wantReason: ReasonPostReply, wantDID: legacyAuthorDID, wantURI: legacyPostURI, + }, + { + name: "nested reply goes only to parent comment author", + comment: CommentRecord{ + URI: "at://" + commenterDID + "/social.coves.community.comment/nested", + CID: "bafycommentnested", AuthorDID: commenterDID, + ParentURI: parentURI, RootURI: rootURI, CreatedAt: createdAt, + }, + wantReason: ReasonCommentReply, wantDID: parentAuthorDID, wantURI: parentURI, + }, + } { + t.Run(test.name, func(t *testing.T) { + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, test.comment) + require.NoError(t, err) + require.Len(t, intents, 1, "missing reply intent for %s", test.comment.URI) + require.Equal(t, Intent{ + Reason: test.wantReason, + RecipientDID: test.wantDID, + ActorDID: commenterDID, + RecordURI: test.comment.URI, + RecordCID: test.comment.CID, + SubjectURI: test.wantURI, + RootPostURI: test.comment.RootURI, + RecordCreatedAt: createdAt, + }, intents[0]) + }) + } +} + +func TestFanoutCommentCreate_LegacyPostMissing(t *testing.T) { + const communityDID = "did:plc:community" + postURI := "at://" + communityDID + "/social.coves.community.post/missing" + comment := CommentRecord{ + URI: "at://did:plc:commenter/social.coves.community.comment/reply", CID: "bafyreply", + AuthorDID: "did:plc:commenter", ParentURI: postURI, RootURI: postURI, + CreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + } + // The URI authority is indexed, so falling back to it would produce an intent. + intents, err := FanoutCommentCreate(context.Background(), commentFanoutLookups{ + indexedUsers: map[string]bool{communityDID: true}, + }, nil, comment) + require.NoError(t, err) + require.Empty(t, intents, "a legacy post without a posts row has no author to notify") +} + +func TestFanoutCommentCreate_ParentCommentTakesPrecedenceOverRootPost(t *testing.T) { + const authorDID = "did:plc:postandcommentauthor" + rootURI := "at://" + authorDID + "/social.coves.community.postv2/post" + parentURI := "at://" + authorDID + "/social.coves.community.comment/parent" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + comment := CommentRecord{ + URI: "at://did:plc:commenter/social.coves.community.comment/reply", CID: "bafyreireply", + AuthorDID: "did:plc:commenter", ParentURI: parentURI, RootURI: rootURI, CreatedAt: createdAt, + } + intents, err := FanoutCommentCreate(context.Background(), commentFanoutLookups{ + indexedUsers: map[string]bool{authorDID: true}, + }, nil, comment) + require.NoError(t, err) + require.Equal(t, []Intent{{ + Reason: ReasonCommentReply, RecipientDID: authorDID, ActorDID: comment.AuthorDID, + RecordURI: comment.URI, RecordCID: comment.CID, SubjectURI: parentURI, + RootPostURI: rootURI, RecordCreatedAt: createdAt, + }}, intents, "a reply to B's comment on B's post must notify B exactly once as commentReply") +} + +func TestFanoutCommentCreate_SuppressesSelfReplies(t *testing.T) { + const authorDID = "did:plc:author" + postURI := "at://" + authorDID + "/social.coves.community.postv2/post" + parentURI := "at://" + authorDID + "/social.coves.community.comment/parent" + legacyPostURI := "at://did:plc:community/social.coves.community.post/post" + lookups := commentFanoutLookups{ + indexedUsers: map[string]bool{authorDID: true}, + legacyPostAuthors: map[string]string{legacyPostURI: authorDID}, + } + for _, test := range []struct { + name string + parentURI string + rootURI string + }{ + {"own postv2 post", postURI, postURI}, + {"own comment", parentURI, postURI}, + {"own legacy post", legacyPostURI, legacyPostURI}, + } { + t.Run(test.name, func(t *testing.T) { + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, CommentRecord{ + URI: "at://" + authorDID + "/social.coves.community.comment/reply", CID: "bafyreply", + AuthorDID: authorDID, ParentURI: test.parentURI, RootURI: test.rootURI, + CreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.Empty(t, intents, "replies to one's own post or comment must not notify oneself") + }) + } +} + +func TestFanoutCommentCreate_SelfReplyOnOthersPostDoesNotNotifyPostAuthor(t *testing.T) { + const ( + commenterDID = "did:plc:commenter" + postAuthorDID = "did:plc:postauthor" + ) + postURI := "at://" + postAuthorDID + "/social.coves.community.postv2/post" + parentURI := "at://" + commenterDID + "/social.coves.community.comment/parent" + intents, err := FanoutCommentCreate(context.Background(), commentFanoutLookups{ + indexedUsers: map[string]bool{commenterDID: true, postAuthorDID: true}, + }, nil, CommentRecord{ + URI: "at://" + commenterDID + "/social.coves.community.comment/reply", CID: "bafyreply", + AuthorDID: commenterDID, ParentURI: parentURI, RootURI: postURI, + CreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.Empty(t, intents, "A replying to A's own comment on B's post must not fall back to notifying B") +} + +func TestFanoutCommentCreate_RecipientNotIndexed(t *testing.T) { + postURI := "at://did:plc:postauthor/social.coves.community.postv2/post" + parentURI := "at://did:plc:commentauthor/social.coves.community.comment/parent" + for _, test := range []struct { + name string + parentURI string + }{ + {"postv2 post reply", postURI}, + {"comment reply", parentURI}, + } { + t.Run(test.name, func(t *testing.T) { + intents, err := FanoutCommentCreate(context.Background(), commentFanoutLookups{}, nil, CommentRecord{ + URI: "at://did:plc:commenter/social.coves.community.comment/reply", CID: "bafyreply", + AuthorDID: "did:plc:commenter", ParentURI: test.parentURI, RootURI: postURI, + CreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.Empty(t, intents, "a recipient without a users row must not receive a reply notification") + }) + } +} + +func TestFanoutCommentCreate_UnsupportedParentCollection(t *testing.T) { + const ( + parentAuthorDID = "did:plc:parentauthor" + rootAuthorDID = "did:plc:rootauthor" + ) + unknownParentURI := "at://" + parentAuthorDID + "/social.coves.community.unknown/record" + rootURI := "at://" + rootAuthorDID + "/social.coves.community.postv2/post" + // Both URI authorities are indexed, so a fallback to either would produce an intent. + lookups := commentFanoutLookups{indexedUsers: map[string]bool{parentAuthorDID: true, rootAuthorDID: true}} + for _, test := range []struct { + name string + parentURI string + rootURI string + }{ + {"unsupported root replied to directly", unknownParentURI, unknownParentURI}, + {"unsupported parent under a post root", unknownParentURI, rootURI}, + } { + t.Run(test.name, func(t *testing.T) { + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, CommentRecord{ + URI: "at://did:plc:commenter/social.coves.community.comment/reply", CID: "bafyreply", + AuthorDID: "did:plc:commenter", ParentURI: test.parentURI, RootURI: test.rootURI, + CreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.Empty(t, intents, "a parent in an unsupported collection has no reply recipient") + }) + } +} + +func TestFanoutCommentCreate_CommentReplyRootMustBeAPost(t *testing.T) { + const ( + parentAuthorDID = "did:plc:parentauthor" + rootAuthorDID = "did:plc:rootauthor" + ) + parentURI := "at://" + parentAuthorDID + "/social.coves.community.comment/parent" + lookups := commentFanoutLookups{indexedUsers: map[string]bool{parentAuthorDID: true, rootAuthorDID: true}} + for _, test := range []struct { + name string + rootURI string + }{ + {"comment root", "at://" + rootAuthorDID + "/social.coves.community.comment/root"}, + {"unknown collection root", "at://" + rootAuthorDID + "/social.coves.community.unknown/root"}, + } { + t.Run(test.name, func(t *testing.T) { + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, CommentRecord{ + URI: "at://did:plc:commenter/social.coves.community.comment/reply", CID: "bafyreply", + AuthorDID: "did:plc:commenter", ParentURI: parentURI, RootURI: test.rootURI, + CreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.Empty(t, intents, "a commentReply notification's root must name a post") + }) + } +} + +// These thread URIs pass the comment consumer's lenient structural check but +// not syntax.ParseATURI. A payload defect is not transient: it must yield no +// recipient rather than an error that stalls indexing. +func TestFanoutCommentCreate_UnparsableThreadURIsHaveNoRecipient(t *testing.T) { + const ( + postAuthorDID = "did:plc:postauthor" + parentAuthorDID = "did:plc:parentauthor" + ) + postURI := "at://" + postAuthorDID + "/social.coves.community.postv2/post" + parentURI := "at://" + parentAuthorDID + "/social.coves.community.comment/parent" + lookups := commentFanoutLookups{indexedUsers: map[string]bool{ + postAuthorDID: true, parentAuthorDID: true, mentionRecipientDDID: true, + }} + for _, test := range []struct { + name string + parentURI string + rootURI string + facetsJSON string + wantMentions []string + }{ + {name: "trailing-slash post replied to directly", parentURI: postURI + "/", rootURI: postURI + "/"}, + { + name: "trailing-slash parent comment does not fall back to the root author", parentURI: parentURI + "/", rootURI: postURI, + facetsJSON: notificationMentionFacets(mentionRecipientDDID), wantMentions: []string{mentionRecipientDDID}, + }, + {name: "trailing-slash root post under a valid parent comment", parentURI: parentURI, rootURI: postURI + "/"}, + {name: "parent that is not an at-uri", parentURI: "not-an-at-uri", rootURI: postURI}, + } { + t.Run(test.name, func(t *testing.T) { + comment := CommentRecord{ + URI: "at://did:plc:commenter/social.coves.community.comment/reply", CID: "bafyreply", + AuthorDID: "did:plc:commenter", ParentURI: test.parentURI, RootURI: test.rootURI, + CreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), FacetsJSON: test.facetsJSON, + } + intents, err := FanoutCommentCreate(context.Background(), lookups, nil, comment) + require.NoError(t, err, "an unparsable thread URI is not a transient failure") + var want []Intent + for _, did := range test.wantMentions { + want = append(want, notificationExpectedIntent(comment, ReasonMention, did, "")) + } + require.Equal(t, want, intents, + "an unparsable thread URI has no derivable reply recipient; a valid post root still carries mentions") + }) + } +} diff --git a/internal/core/notifications/fanout_vote_first_upvote_test.go b/internal/core/notifications/fanout_vote_first_upvote_test.go new file mode 100644 index 0000000..24e70c3 --- /dev/null +++ b/internal/core/notifications/fanout_vote_first_upvote_test.go @@ -0,0 +1,112 @@ +package notifications + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFanoutVoteCreate_EarlierUpvoteDeletesIfEmpty(t *testing.T) { + for _, subject := range []struct { + name, uri, root string + }{ + {"post", voteFanoutPost, voteFanoutPost}, + {"comment", voteFanoutComment, voteFanoutPost}, + } { + t.Run(subject.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + vote.URI = "at://" + voteFanoutVoter + "/social.coves.community.vote/revote" + vote.SubjectURI, vote.SubjectRootURI = subject.uri, subject.root + lookups.earlierUpvoteExists = true + var calls []earlierUpvoteCall + lookups.earlierUpvoteCalls = &calls + + intent, err := FanoutVoteCreate(context.Background(), lookups, nil, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupIntent{ + Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutAuthor, + SubjectURI: subject.uri, + }, intent, "another upvote by the same voter must not re-bump this group") + require.Equal(t, []earlierUpvoteCall{{voteFanoutVoter, subject.uri, vote.URI}}, calls, + "check exactly once for another upvote, excluding this vote URI") + }) + } +} + +func TestFanoutVoteCreate_FirstUpvoteBumps(t *testing.T) { + for _, subject := range []struct { + name, uri, root string + }{ + {"post", voteFanoutPost, voteFanoutPost}, + {"comment", voteFanoutComment, voteFanoutPost}, + } { + t.Run(subject.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + vote.URI = "at://" + voteFanoutVoter + "/social.coves.community.vote/first" + vote.SubjectURI, vote.SubjectRootURI = subject.uri, subject.root + var calls []earlierUpvoteCall + lookups.earlierUpvoteCalls = &calls + + intent, err := FanoutVoteCreate(context.Background(), lookups, nil, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupIntent{ + Action: UpvoteGroupBump, RecipientDID: voteFanoutAuthor, + SubjectURI: subject.uri, RootPostURI: subject.root, + }, intent) + require.Equal(t, []earlierUpvoteCall{{voteFanoutVoter, subject.uri, vote.URI}}, calls, + "a qualifying first upvote must check prior upvotes exactly once") + }) + } +} + +func TestFanoutVoteCreate_NonBumpingVotesSkipEarlierUpvoteLookup(t *testing.T) { + for _, test := range []struct { + name string + setup func(*commentFanoutLookups, *VoteRecord) + }{ + {"downvote", func(_ *commentFanoutLookups, vote *VoteRecord) { vote.Direction = "down" }}, + {"self_vote", func(_ *commentFanoutLookups, vote *VoteRecord) { vote.VoterDID = voteFanoutAuthor }}, + {"before_activation", func(lookups *commentFanoutLookups, vote *VoteRecord) { + lookups.activatedAt = vote.CreatedAt.Add(time.Nanosecond) + }}, + {"older_than_seven_days", func(lookups *commentFanoutLookups, vote *VoteRecord) { + lookups.indexTime = vote.CreatedAt.Add(7*24*time.Hour + time.Nanosecond) + }}, + {"erased_voter", func(_ *commentFanoutLookups, vote *VoteRecord) { + vote.VoterErased = true + }}, + {"aggregator_voter", func(lookups *commentFanoutLookups, _ *VoteRecord) { + lookups.aggregatorAccounts = map[string]bool{voteFanoutVoter: true} + }}, + {"recipient_not_indexed", func(lookups *commentFanoutLookups, _ *VoteRecord) { + delete(lookups.indexedUsers, voteFanoutAuthor) + }}, + {"recipient_blocks_voter", func(lookups *commentFanoutLookups, _ *VoteRecord) { + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: voteFanoutAuthor, blockedDID: voteFanoutVoter}: true} + }}, + {"voter_blocks_recipient", func(lookups *commentFanoutLookups, _ *VoteRecord) { + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: voteFanoutVoter, blockedDID: voteFanoutAuthor}: true} + }}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + vote.URI = "at://" + voteFanoutVoter + "/social.coves.community.vote/ineligible" + test.setup(&lookups, &vote) + // A lookup here would fail rather than silently return an earlier vote. + lookups.earlierUpvoteError = errors.New("earlier-upvote lookup must not run") + var calls []earlierUpvoteCall + lookups.earlierUpvoteCalls = &calls + + intent, err := FanoutVoteCreate(context.Background(), lookups, nil, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupIntent{ + Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutAuthor, + SubjectURI: voteFanoutPost, + }, intent) + require.Empty(t, calls, "non-bumping votes must not check for earlier upvotes") + }) + } +} diff --git a/internal/core/notifications/fanout_vote_maintenance_test.go b/internal/core/notifications/fanout_vote_maintenance_test.go new file mode 100644 index 0000000..8eb008b --- /dev/null +++ b/internal/core/notifications/fanout_vote_maintenance_test.go @@ -0,0 +1,356 @@ +package notifications + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func requireVoteDeleteIfEmpty(t *testing.T, lookups commentFanoutLookups, bridgeHosts BridgeHostChecker, vote VoteRecord, recipient string) { + t.Helper() + intent, err := FanoutVoteCreate(context.Background(), lookups, bridgeHosts, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupDeleteIfEmpty, intent.Action, "a resolved non-bump vote must request delete-if-empty") + require.Equal(t, recipient, intent.RecipientDID) + require.Equal(t, vote.SubjectURI, intent.SubjectURI) +} + +func TestFanoutVoteCreate_DeleteIfEmptyDownvotes(t *testing.T) { + t.Parallel() + for _, subject := range []struct { + name, uri string + legacyCalls int + }{ + {"postv2_no_lookups", voteFanoutPost, 0}, + {"legacy_only_author_lookup", voteFanoutLegacyPost, 1}, + } { + t.Run(subject.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + vote.SubjectURI, vote.Direction = subject.uri, "down" + var legacyCalls, activationCalls, indexCalls, aggregatorCalls int + var recipientCalls [][]string + lookups.legacyPostAuthorCalls = &legacyCalls + lookups.activatedAtCalls = &activationCalls + lookups.indexTimeCalls = &indexCalls + lookups.isAggregatorCalls = &aggregatorCalls + lookups.recipientFactsCalls = &recipientCalls + requireVoteDeleteIfEmpty(t, lookups, nil, vote, voteFanoutAuthor) + require.Equal(t, subject.legacyCalls, legacyCalls, "resolve only the legacy post author when necessary") + require.Zero(t, activationCalls, "downvotes must not read activation") + require.Zero(t, indexCalls, "downvotes must not read index time") + require.Zero(t, aggregatorCalls, "downvotes must not read voter aggregator status") + require.Empty(t, recipientCalls, "downvotes must not read recipient facts") + }) + } +} + +func TestFanoutVoteCreate_DeleteIfEmptySelfVotes(t *testing.T) { + t.Parallel() + for _, subject := range []struct{ name, uri, root string }{ + {"postv2", voteFanoutPost, ""}, + {"comment", voteFanoutComment, voteFanoutPost}, + {"legacy_post_resolves_row_author", voteFanoutLegacyPost, ""}, + } { + t.Run(subject.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + vote.SubjectURI, vote.SubjectRootURI = subject.uri, subject.root + vote.VoterDID = voteFanoutAuthor + requireVoteDeleteIfEmpty(t, lookups, nil, vote, voteFanoutAuthor) + }) + } +} + +func TestFanoutVoteCreate_DeleteIfEmptyVoterEligibility(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + setup func(*commentFanoutLookups, *VoteRecord) + }{ + {"erased_voter", func(_ *commentFanoutLookups, vote *VoteRecord) { + vote.VoterErased = true + }}, + {"aggregator_voter", func(lookups *commentFanoutLookups, _ *VoteRecord) { + lookups.aggregatorAccounts = map[string]bool{voteFanoutVoter: true} + }}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + test.setup(&lookups, &vote) + requireVoteDeleteIfEmpty(t, lookups, nil, vote, voteFanoutAuthor) + }) + } +} + +func TestFanoutVoteCreate_DeleteIfEmptyBlocks(t *testing.T) { + t.Parallel() + for _, test := range []struct{ name, blocker, blocked string }{ + {"recipient_blocks_voter", voteFanoutAuthor, voteFanoutVoter}, + {"voter_blocks_recipient", voteFanoutVoter, voteFanoutAuthor}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: test.blocker, blockedDID: test.blocked}: true} + requireVoteDeleteIfEmpty(t, lookups, nil, vote, voteFanoutAuthor) + }) + } +} + +func TestFanoutVoteCreate_DeleteIfEmptyTimeGates(t *testing.T) { + t.Parallel() + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + createdAt, activatedAt, indexTime time.Time + }{ + {"before_activation", indexTime, indexTime.Add(time.Nanosecond), indexTime.Add(time.Minute)}, + {"eight_days_old", indexTime.Add(-8 * 24 * time.Hour), indexTime.Add(-30 * 24 * time.Hour), indexTime}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + vote.CreatedAt = test.createdAt + lookups.activatedAt, lookups.indexTime = test.activatedAt, test.indexTime + requireVoteDeleteIfEmpty(t, lookups, nil, vote, voteFanoutAuthor) + }) + } +} + +func TestFanoutVoteCreate_DeleteIfEmptyRecipientEligibility(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + setup func(*commentFanoutLookups) + bridgeHosts BridgeHostChecker + }{ + {"unindexed", func(lookups *commentFanoutLookups) { + delete(lookups.indexedUsers, voteFanoutAuthor) + }, nil}, + {"erased", func(lookups *commentFanoutLookups) { + lookups.erasedAccounts = map[string]bool{voteFanoutAuthor: true} + }, nil}, + {"aggregator", func(lookups *commentFanoutLookups) { + lookups.aggregatorAccounts = map[string]bool{voteFanoutAuthor: true} + }, nil}, + {"trusted_bridge_pds", func(lookups *commentFanoutLookups) { + lookups.userPDSURLs = map[string]string{voteFanoutAuthor: voteFanoutBridgePDS} + }, ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{voteFanoutBridgePDS: true}}}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + test.setup(&lookups) + requireVoteDeleteIfEmpty(t, lookups, test.bridgeHosts, vote, voteFanoutAuthor) + }) + } +} + +func TestFanoutVoteCreate_UnresolvableSubjectsStayNoChange(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, subject, root string + missingLegacyPost bool + }{ + {"unparsable_uri", voteFanoutPost + "/", "", false}, + {"unsupported_collection", "at://" + voteFanoutAuthor + "/app.bsky.feed.post/post", "", false}, + {"legacy_post_without_row", voteFanoutLegacyPost, "", true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + // Same fake and voter: a resolvable sibling must actually produce an intent. + if test.missingLegacyPost { + vote.SubjectURI = voteFanoutLegacyPost + } + requireVoteBump(t, lookups, nil, vote, voteFanoutAuthor, vote.SubjectURI) + if test.missingLegacyPost { + delete(lookups.legacyPostAuthors, voteFanoutLegacyPost) + } + vote.SubjectURI, vote.SubjectRootURI = test.subject, test.root + intent, err := FanoutVoteCreate(context.Background(), lookups, nil, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupIntent{}, intent, "unresolvable subjects must not target any group") + }) + } +} + +// The comment URI's authority names the recipient whatever the stored root is, +// for example after a resurrection re-creates the comment under a different +// root. Only a bump needs a post root, so without one every vote change asks to +// delete the commenter's group if empty, before any eligibility lookup. +func TestFanoutVote_CommentWithoutPostRootDeletesIfEmpty(t *testing.T) { + t.Parallel() + for _, root := range []struct{ name, uri string }{ + {"unparsable_root", voteFanoutPost + "/"}, + {"empty_root", ""}, + {"nonpost_root", voteFanoutComment}, + } { + t.Run(root.name, func(t *testing.T) { + t.Parallel() + for _, operation := range []struct { + name string + fanout func(commentFanoutLookups, VoteRecord) (UpvoteGroupIntent, error) + }{ + {"create_upvote", func(lookups commentFanoutLookups, vote VoteRecord) (UpvoteGroupIntent, error) { + return FanoutVoteCreate(context.Background(), lookups, nil, vote) + }}, + {"create_downvote", func(lookups commentFanoutLookups, vote VoteRecord) (UpvoteGroupIntent, error) { + vote.Direction = "down" + return FanoutVoteCreate(context.Background(), lookups, nil, vote) + }}, + {"removal", func(lookups commentFanoutLookups, vote VoteRecord) (UpvoteGroupIntent, error) { + return FanoutVoteRemoval(context.Background(), lookups, vote) + }}, + } { + t.Run(operation.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + lookups.indexedUsers[voteFanoutCommenter] = true + vote.SubjectURI, vote.SubjectRootURI = voteFanoutCommenterComment, voteFanoutPost + // Same fake and voter: under a post root this upvote bumps the commenter's group. + requireVoteBump(t, lookups, nil, vote, voteFanoutCommenter, voteFanoutPost) + var legacyCalls, activationCalls, indexCalls, aggregatorCalls int + var recipientCalls [][]string + lookups.legacyPostAuthorCalls = &legacyCalls + lookups.activatedAtCalls = &activationCalls + lookups.indexTimeCalls = &indexCalls + lookups.isAggregatorCalls = &aggregatorCalls + lookups.recipientFactsCalls = &recipientCalls + vote.SubjectRootURI = root.uri + intent, err := operation.fanout(lookups, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupIntent{ + Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutCommenter, + SubjectURI: voteFanoutCommenterComment, + }, intent, "a comment without a post root must ask to delete the commenter's group if empty") + require.Zero(t, legacyCalls, "a comment vote must not look up a post author") + require.Zero(t, activationCalls, "a comment without a post root must not read activation") + require.Zero(t, indexCalls, "a comment without a post root must not read index time") + require.Zero(t, aggregatorCalls, "a comment without a post root must not read voter aggregator status") + require.Empty(t, recipientCalls, "a comment without a post root must not read recipient facts") + }) + } + }) + } +} + +func TestFanoutVoteRemoval_ResolvedSubjectsIgnoreVoteEligibility(t *testing.T) { + t.Parallel() + for _, subject := range []struct { + name, uri, root string + legacyCalls int + }{ + {"postv2", voteFanoutPost, "", 0}, + {"comment", voteFanoutComment, voteFanoutPost, 0}, + {"legacy_post", voteFanoutLegacyPost, "", 1}, + } { + t.Run(subject.name, func(t *testing.T) { + t.Parallel() + for _, scenario := range []struct { + name string + setup func(*commentFanoutLookups, *VoteRecord) + }{ + {"upvote", func(_ *commentFanoutLookups, _ *VoteRecord) {}}, + {"downvote", func(_ *commentFanoutLookups, vote *VoteRecord) { + vote.Direction = "down" + }}, + {"self_vote", func(_ *commentFanoutLookups, vote *VoteRecord) { + vote.VoterDID = voteFanoutAuthor + }}, + {"erased_voter_before_activation", func(lookups *commentFanoutLookups, vote *VoteRecord) { + vote.VoterErased = true + lookups.activatedAt = vote.CreatedAt.Add(time.Second) + lookups.indexTime = vote.CreatedAt.Add(time.Minute) + }}, + {"aggregator_voter_eight_days_old", func(lookups *commentFanoutLookups, vote *VoteRecord) { + lookups.aggregatorAccounts = map[string]bool{voteFanoutVoter: true} + lookups.indexTime = vote.CreatedAt.Add(8 * 24 * time.Hour) + lookups.activatedAt = vote.CreatedAt.Add(-24 * time.Hour) + }}, + // The vote consumer passes only the subject URI on removal. + {"subject_uri_only", func(_ *commentFanoutLookups, vote *VoteRecord) { + *vote = VoteRecord{SubjectURI: vote.SubjectURI} + }}, + } { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + vote.SubjectURI, vote.SubjectRootURI = subject.uri, subject.root + scenario.setup(&lookups, &vote) + var legacyCalls, activationCalls, indexCalls, aggregatorCalls int + var recipientCalls [][]string + lookups.legacyPostAuthorCalls = &legacyCalls + lookups.activatedAtCalls = &activationCalls + lookups.indexTimeCalls = &indexCalls + lookups.isAggregatorCalls = &aggregatorCalls + lookups.recipientFactsCalls = &recipientCalls + intent, err := FanoutVoteRemoval(context.Background(), lookups, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupDeleteIfEmpty, intent.Action, + "removing a vote from a resolved subject must request delete-if-empty") + require.Equal(t, voteFanoutAuthor, intent.RecipientDID) + require.Equal(t, vote.SubjectURI, intent.SubjectURI) + require.Equal(t, subject.legacyCalls, legacyCalls, + "only legacy posts need an author lookup") + require.Zero(t, activationCalls, "removal must not check activation") + require.Zero(t, indexCalls, "removal must not check freshness") + require.Zero(t, aggregatorCalls, "removal must not check voter aggregator status") + require.Empty(t, recipientCalls, "removal must not check recipient facts") + }) + } + }) + } +} + +func TestFanoutVoteRemoval_UnresolvableSubjectsStayNoChange(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name, subject, root string + missingLegacyPost bool + }{ + {"unparsable_uri", voteFanoutPost + "/", "", false}, + {"unsupported_collection", "at://" + voteFanoutAuthor + "/app.bsky.feed.post/post", "", false}, + {"legacy_post_without_row", voteFanoutLegacyPost, "", true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + // Same fake: a resolvable sibling must actually produce an intent. + if test.missingLegacyPost { + vote.SubjectURI = voteFanoutLegacyPost + } + control, err := FanoutVoteRemoval(context.Background(), lookups, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupIntent{ + Action: UpvoteGroupDeleteIfEmpty, RecipientDID: voteFanoutAuthor, SubjectURI: vote.SubjectURI, + }, control, "the resolvable sibling must request delete-if-empty") + if test.missingLegacyPost { + delete(lookups.legacyPostAuthors, voteFanoutLegacyPost) + } + vote.SubjectURI, vote.SubjectRootURI = test.subject, test.root + intent, err := FanoutVoteRemoval(context.Background(), lookups, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupIntent{}, intent, "unresolvable subjects must not target any group") + }) + } +} + +func TestFanoutVoteRemoval_LegacyAuthorLookupError(t *testing.T) { + t.Parallel() + lookups, vote := qualifyingVoteFanout() + vote.SubjectURI = voteFanoutLegacyPost + lookupError := errors.New("legacy post author lookup failed") + lookups.legacyPostAuthorError = lookupError + var legacyCalls int + lookups.legacyPostAuthorCalls = &legacyCalls + intent, err := FanoutVoteRemoval(context.Background(), lookups, vote) + require.ErrorIs(t, err, lookupError, "legacy author lookup failure must propagate wrapped") + require.Equal(t, UpvoteGroupIntent{}, intent) + require.Equal(t, 1, legacyCalls) +} diff --git a/internal/core/notifications/fanout_votes_test.go b/internal/core/notifications/fanout_votes_test.go new file mode 100644 index 0000000..ddb1366 --- /dev/null +++ b/internal/core/notifications/fanout_votes_test.go @@ -0,0 +1,270 @@ +package notifications + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + voteFanoutVoter = "did:plc:upvotefanoutvoter" + voteFanoutAuthor = "did:plc:upvotefanoutauthor" + voteFanoutPost = "at://" + voteFanoutAuthor + "/social.coves.community.postv2/post" + voteFanoutComment = "at://" + voteFanoutAuthor + "/social.coves.community.comment/comment" + voteFanoutLegacyPost = "at://did:plc:upvotefanoutcommunity/social.coves.community.post/post" + voteFanoutBridgePDS = "https://bridge.test" + // The commenter's comment sits under voteFanoutPost, whose author differs. + voteFanoutCommenter = "did:plc:upvotefanoutcommenter" + voteFanoutCommenterComment = "at://" + voteFanoutCommenter + "/social.coves.community.comment/comment" +) + +func qualifyingVoteFanout() (commentFanoutLookups, VoteRecord) { + return commentFanoutLookups{ + indexedUsers: map[string]bool{voteFanoutAuthor: true, voteFanoutVoter: true}, + legacyPostAuthors: map[string]string{voteFanoutLegacyPost: voteFanoutAuthor}, + }, VoteRecord{ + VoterDID: voteFanoutVoter, SubjectURI: voteFanoutPost, Direction: "up", + CreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + } +} + +func requireVoteBump(t *testing.T, lookups commentFanoutLookups, bridgeHosts BridgeHostChecker, vote VoteRecord, recipient, root string) { + t.Helper() + intent, err := FanoutVoteCreate(context.Background(), lookups, bridgeHosts, vote) + require.NoError(t, err) + require.Equal(t, UpvoteGroupIntent{ + Action: UpvoteGroupBump, RecipientDID: recipient, + SubjectURI: vote.SubjectURI, RootPostURI: root, + }, intent, "a qualifying upvote must request exactly this recipient/subject group") +} + +func requireVoteNoBump(t *testing.T, lookups commentFanoutLookups, bridgeHosts BridgeHostChecker, vote VoteRecord) { + t.Helper() + intent, err := FanoutVoteCreate(context.Background(), lookups, bridgeHosts, vote) + require.NoError(t, err) + require.NotEqual(t, UpvoteGroupBump, intent.Action, "this vote must not bump a group") +} + +func TestFanoutVoteCreate_QualifyingSubjects(t *testing.T) { + for _, test := range []struct { + name, subject, root string + }{ + {"postv2", voteFanoutPost, voteFanoutPost}, + {"comment", voteFanoutComment, voteFanoutPost}, + {"legacy_post_author_not_community", voteFanoutLegacyPost, voteFanoutLegacyPost}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + vote.SubjectURI = test.subject + if test.name == "comment" { + vote.SubjectRootURI = test.root + } + requireVoteBump(t, lookups, nil, vote, voteFanoutAuthor, test.root) + }) + } +} + +// The comment URI's authority names the recipient and the self rule. The root +// post's author is someone else, and a legacy root's author is never looked up. +func TestFanoutVoteCreate_CommentRecipientIsCommenterNotRootAuthor(t *testing.T) { + for _, test := range []struct { + name, voter, root string + wantBump bool + }{ + {"third_party_voter", voteFanoutVoter, voteFanoutPost, true}, + {"root_post_author_voter", voteFanoutAuthor, voteFanoutPost, true}, + {"commenter_self_upvote", voteFanoutCommenter, voteFanoutPost, false}, + {"legacy_root_third_party_voter", voteFanoutVoter, voteFanoutLegacyPost, true}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + lookups.indexedUsers[voteFanoutCommenter] = true + var legacyCalls int + lookups.legacyPostAuthorCalls = &legacyCalls + vote.VoterDID = test.voter + vote.SubjectURI, vote.SubjectRootURI = voteFanoutCommenterComment, test.root + intent, err := FanoutVoteCreate(context.Background(), lookups, nil, vote) + require.NoError(t, err) + require.Zero(t, legacyCalls, "a comment upvote must not look up its root post's author") + if !test.wantBump { + require.NotEqual(t, UpvoteGroupBump, intent.Action, "the commenter's own upvote must not bump a group") + return + } + require.Equal(t, UpvoteGroupIntent{ + Action: UpvoteGroupBump, RecipientDID: voteFanoutCommenter, + SubjectURI: voteFanoutCommenterComment, RootPostURI: test.root, + }, intent, "the commenter, not the root post's author, receives the group") + }) + } +} + +func TestFanoutVoteCreate_DownSkipsEveryLookup(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + vote.Direction = "down" + var legacyCalls, activationCalls, indexCalls, aggregatorCalls int + var recipientCalls [][]string + lookups.legacyPostAuthorCalls = &legacyCalls + lookups.activatedAtCalls = &activationCalls + lookups.indexTimeCalls = &indexCalls + lookups.isAggregatorCalls = &aggregatorCalls + lookups.recipientFactsCalls = &recipientCalls + requireVoteNoBump(t, lookups, nil, vote) + require.Zero(t, legacyCalls, "downvotes must not resolve a legacy author") + require.Zero(t, activationCalls, "downvotes must not read activation") + require.Zero(t, indexCalls, "downvotes must not read index time") + require.Zero(t, aggregatorCalls, "downvotes must not check voter aggregator status") + require.Empty(t, recipientCalls, "downvotes must not look up recipients") +} + +func TestFanoutVoteCreate_SelfUpvotesDoNotBump(t *testing.T) { + for _, test := range []struct{ name, subject, root string }{ + {"own_postv2", voteFanoutPost, ""}, + {"own_comment", voteFanoutComment, voteFanoutPost}, + {"own_legacy_post", voteFanoutLegacyPost, ""}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + vote.VoterDID = voteFanoutAuthor + vote.SubjectURI, vote.SubjectRootURI = test.subject, test.root + requireVoteNoBump(t, lookups, nil, vote) + }) + } +} + +func TestFanoutVoteCreate_VoterEligibility(t *testing.T) { + for _, test := range []struct { + name string + setup func(*commentFanoutLookups, *VoteRecord) + bridgeHost BridgeHostChecker + wantBump bool + }{ + {"erased_voter", func(_ *commentFanoutLookups, vote *VoteRecord) { + vote.VoterErased = true + }, nil, false}, + {"aggregator_voter", func(lookups *commentFanoutLookups, _ *VoteRecord) { + lookups.aggregatorAccounts = map[string]bool{voteFanoutVoter: true} + }, nil, false}, + {"voter_without_users_row", func(lookups *commentFanoutLookups, _ *VoteRecord) { + delete(lookups.indexedUsers, voteFanoutVoter) + }, nil, true}, + {"bridge_hosted_voter_native_recipient", func(lookups *commentFanoutLookups, _ *VoteRecord) { + lookups.userPDSURLs = map[string]string{voteFanoutVoter: voteFanoutBridgePDS} + }, ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{voteFanoutBridgePDS: true}}, true}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + test.setup(&lookups, &vote) + if !test.wantBump { + requireVoteNoBump(t, lookups, test.bridgeHost, vote) + return + } + requireVoteBump(t, lookups, test.bridgeHost, vote, voteFanoutAuthor, voteFanoutPost) + if checker, ok := test.bridgeHost.(*notificationTestBridgeHosts); ok { + require.Equal(t, []string{"https://native.pds.test"}, checker.checkedURLs, + "only the recipient's PDS is checked for bridge trust") + } + }) + } +} + +func TestFanoutVoteCreate_BlocksBothDirections(t *testing.T) { + for _, test := range []struct{ name, blocker, blocked string }{ + {"recipient_blocks_voter", voteFanoutAuthor, voteFanoutVoter}, + {"voter_blocks_recipient", voteFanoutVoter, voteFanoutAuthor}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + lookups.blocks = map[commentFanoutBlock]bool{{blockerDID: test.blocker, blockedDID: test.blocked}: true} + requireVoteNoBump(t, lookups, nil, vote) + }) + } +} + +func TestFanoutVoteCreate_TimeGateBoundaries(t *testing.T) { + indexTime := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + createdAt, activatedAt, indexTime time.Time + wantBump bool + }{ + {"before_activation", indexTime, indexTime.Add(time.Nanosecond), indexTime.Add(time.Minute), false}, + {"exactly_at_activation", indexTime, indexTime, indexTime.Add(time.Minute), true}, + {"older_than_seven_days", indexTime.Add(-7*24*time.Hour - time.Microsecond), indexTime.Add(-30 * 24 * time.Hour), indexTime, false}, + {"exactly_seven_days_old", indexTime.Add(-7 * 24 * time.Hour), indexTime.Add(-30 * 24 * time.Hour), indexTime, true}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + vote.CreatedAt = test.createdAt + lookups.activatedAt, lookups.indexTime = test.activatedAt, test.indexTime + if test.wantBump { + requireVoteBump(t, lookups, nil, vote, voteFanoutAuthor, voteFanoutPost) + } else { + requireVoteNoBump(t, lookups, nil, vote) + } + }) + } +} + +func TestFanoutVoteCreate_RecipientEligibility(t *testing.T) { + for _, test := range []struct { + name string + setup func(*commentFanoutLookups) + bridgeHosts BridgeHostChecker + wantBump bool + }{ + {"unindexed_recipient", func(lookups *commentFanoutLookups) { + delete(lookups.indexedUsers, voteFanoutAuthor) + }, nil, false}, + {"erased_recipient", func(lookups *commentFanoutLookups) { + lookups.erasedAccounts = map[string]bool{voteFanoutAuthor: true} + }, nil, false}, + {"aggregator_recipient", func(lookups *commentFanoutLookups) { + lookups.aggregatorAccounts = map[string]bool{voteFanoutAuthor: true} + }, nil, false}, + {"bridge_hosted_recipient", func(lookups *commentFanoutLookups) { + lookups.userPDSURLs = map[string]string{voteFanoutAuthor: voteFanoutBridgePDS} + }, ¬ificationTestBridgeHosts{trustedURLs: map[string]bool{voteFanoutBridgePDS: true}}, false}, + {"bridge_hosted_recipient_nil_checker", func(lookups *commentFanoutLookups) { + lookups.userPDSURLs = map[string]string{voteFanoutAuthor: voteFanoutBridgePDS} + }, nil, true}, + {"bridge_hosted_recipient_nil_pointer_checker", func(lookups *commentFanoutLookups) { + lookups.userPDSURLs = map[string]string{voteFanoutAuthor: voteFanoutBridgePDS} + }, (*notificationTestBridgeHosts)(nil), true}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + test.setup(&lookups) + if test.wantBump { + requireVoteBump(t, lookups, test.bridgeHosts, vote, voteFanoutAuthor, voteFanoutPost) + } else { + requireVoteNoBump(t, lookups, test.bridgeHosts, vote) + } + }) + } +} + +func TestFanoutVoteCreate_UnsupportedSubjectsHaveNoIntentOrError(t *testing.T) { + for _, test := range []struct { + name, subject, root string + missingLegacyPost bool + }{ + {"unparsable_subject", voteFanoutPost + "/", "", false}, + {"unsupported_collection", "at://" + voteFanoutAuthor + "/app.bsky.feed.post/post", "", false}, + {"legacy_post_without_row", voteFanoutLegacyPost, "", true}, + {"comment_with_unparsable_root", voteFanoutComment, voteFanoutPost + "/", false}, + {"comment_with_empty_root", voteFanoutComment, "", false}, + {"comment_with_comment_root", voteFanoutComment, voteFanoutComment, false}, + } { + t.Run(test.name, func(t *testing.T) { + lookups, vote := qualifyingVoteFanout() + vote.SubjectURI, vote.SubjectRootURI = test.subject, test.root + if test.missingLegacyPost { + delete(lookups.legacyPostAuthors, voteFanoutLegacyPost) + lookups.indexedUsers["did:plc:upvotefanoutcommunity"] = true + } + requireVoteNoBump(t, lookups, nil, vote) + }) + } +} diff --git a/internal/core/notifications/interfaces.go b/internal/core/notifications/interfaces.go new file mode 100644 index 0000000..b1b937c --- /dev/null +++ b/internal/core/notifications/interfaces.go @@ -0,0 +1,191 @@ +package notifications + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// Lookups are the reads a fan-out needs, bound to the index transaction. +type Lookups interface { + // LegacyPostAuthor returns posts.author_did for a legacy + // social.coves.community.post URI; found is false when no row exists. + LegacyPostAuthor(ctx context.Context, postURI string) (authorDID string, found bool, err error) + // ActivatedAt returns notification_activation.activated_at. A missing row + // is an error, never "no cutoff". + ActivatedAt(ctx context.Context) (time.Time, error) + // IndexTime returns the index transaction's timestamp, now(). Writes set + // notifications.sort_at to clock_timestamp(), which is at or after it. + IndexTime(ctx context.Context) (time.Time, error) + // IsAggregator reports whether did is in aggregators. It is kept for 06's + // voter eligibility, which must not require a users row. + IsAggregator(ctx context.Context, did string) (bool, error) + // RecipientFacts returns the per-recipient facts for each of recipientDIDs + // that has a users row; a DID without one is absent from the map. + RecipientFacts(ctx context.Context, actorDID string, recipientDIDs []string) (map[string]RecipientFacts, error) + // EarlierUpvoteExists reports whether voterDID has another votes row on + // subjectURI, live or soft-deleted and under any rkey, with direction 'up' + // and a URI other than voteURI. + EarlierUpvoteExists(ctx context.Context, voterDID, subjectURI, voteURI string) (bool, error) + // ExistingMentionRecipients returns the recipient DIDs of every surviving + // notification with reason='mention' and this record_uri. Its length is + // the used portion of MaxMentionsPerRecord's budget. + ExistingMentionRecipients(ctx context.Context, recordURI string) ([]string, error) + // ReferenceStates classifies uris in one transaction-bound lookup. A URI is + // in the map when it is an indexed post or comment row that is withdrawn, + // when it has an active admin removal decision, even if it is not indexed, + // or, as ReferenceUnindexed, when it names a post or comment with neither + // an indexed row nor an active removal. A URI absent from the map is an + // indexed, live row or a record in a collection that is never indexed. + ReferenceStates(ctx context.Context, uris []string) (map[string]ReferenceState, error) +} + +// RecipientFacts is what the per-recipient write-time rules read about one DID. +// The zero value describes an eligible recipient, and it is meaningful only for +// a DID present in the map Lookups.RecipientFacts returns: an absent DID has no +// users row and is never notified. +type RecipientFacts struct { + // PDSURL is the recipient's users.pds_url. Fan-out suppresses the recipient + // when the BridgeHostChecker trusts this host. + PDSURL string + // Erased reports a deleted_accounts row for the recipient. + Erased bool + // Aggregator reports an aggregators row for the recipient. + Aggregator bool + // Community reports a communities row for the recipient. Fan-out uses it + // only to suppress mentions; a community can still receive a reply. + Community bool + // BlockedWithActor reports a user_blocks row in either direction between the + // recipient and the actor. + BlockedWithActor bool +} + +// BridgeHostChecker reports whether a PDS URL is a trusted bridge PDS host. +// *jetstream.BridgeTrust satisfies it. A nil BridgeHostChecker, including a nil +// pointer stored in the interface such as a nil *jetstream.BridgeTrust, trusts no +// host. Fan-out calls TrustsPDS on a nil pointer, so an implementation must return +// false for a nil receiver, as *jetstream.BridgeTrust does. +type BridgeHostChecker interface { + TrustsPDS(pdsURL string) bool +} + +// Repository writes notifications inside a consumer's own index transaction. +// Consumers depend on this interface and never import the service. +type Repository interface { + // ErasureGateTx takes the shared erasure lock for actorDID and then reports + // whether the actor is erased. Call it before touching any content row. + ErasureGateTx(ctx context.Context, tx *sql.Tx, actorDID string) (erased bool, err error) + // LookupsTx returns Lookups that read through tx. + LookupsTx(tx *sql.Tx) Lookups + // ApplyTx writes intents through tx. + ApplyTx(ctx context.Context, tx *sql.Tx, intents []Intent) error + // ApplyUpvoteGroupTx inserts or bumps an upvote group through tx, deletes it + // when no qualifying native upvote remains and, with bridged totals enabled, + // the bridged total is 0, or makes no database call for a + // no-change intent. RootPostURI is written only on insert; a bump keeps the + // stored root, so a comment re-created under a different root needs its own + // update of the group. An upsert whose recipient was erased after the + // fan-out read (23503 on notifications_recipient_did_fkey) is skipped with + // a nil error and the caller's earlier writes in tx kept; any other error + // is returned. A caller applying delete-if-empty must hold the subject's + // posts or comments row lock, take it first, or re-check in a separate + // statement: at READ COMMITTED the DELETE's NOT EXISTS reads its original + // snapshot, so an upvote committed by a concurrent bump it waited on goes + // unseen and the group is deleted. + ApplyUpvoteGroupTx(ctx context.Context, tx *sql.Tx, intent UpvoteGroupIntent) error + // ReplaceUpvoteGroupRootTx sets root_post_uri on recipientDID's upvote group + // whose subject is subjectURI, through tx. A rootPostURI that does not name a + // post collection leaves the group's root unchanged. + ReplaceUpvoteGroupRootTx(ctx context.Context, tx *sql.Tx, recipientDID, subjectURI, rootPostURI string) error + // RecordPostAuthorDeleteWithdrawalTx records the publicly visible postv2 at + // author deletion in tx, before acceptance is withdrawn. + RecordPostAuthorDeleteWithdrawalTx(ctx context.Context, tx *sql.Tx, postURI string) error + // RepairResurrectedCommentNotificationsTx reconciles kept rows with the + // resurrected comment's new threading in tx before its create fan-out. It + // deletes the record's reply rows whose subject is not replySubjectURI ("" when + // no reply resolves deletes them all), and moves the remaining rows to + // rootPostURI when that names a post. + RepairResurrectedCommentNotificationsTx(ctx context.Context, tx *sql.Tx, recordURI, replySubjectURI, rootPostURI string) error + // DeleteReplyRecipientMentionsTx deletes, through tx, the record's mention rows + // held by a recipient who also holds a reply row for the record. It runs after + // a resurrection's create fan-out, so a mention goes only when a reply replaced it. + DeleteReplyRecipientMentionsTx(ctx context.Context, tx *sql.Tx, recordURI string) error +} + +// RetentionSweeper removes one bounded batch per call from each retention category. +type RetentionSweeper interface { + SweepReadNotifications(ctx context.Context) (int64, error) + SweepUnreadOverflow(ctx context.Context) (int64, error) + SweepEmptyUpvoteGroups(ctx context.Context) (int64, error) + SweepHiddenReferenceNotifications(ctx context.Context) (int64, error) +} + +// ReadRepository reads a recipient's notifications through the one read-time +// visibility predicate. +type ReadRepository interface { + CountUnread(ctx context.Context, recipientDID string) (int, error) + UpdateSeen(ctx context.Context, did string, seenAt time.Time) error + List(ctx context.Context, recipientDID, cursor string, limit int) (ListPage, error) +} + +// ListedNotification is one listed notification row ("" or zero for NULL columns). +// RootPost, Subject, and Record carry each reply reference's read-time state and +// current indexed CID, independent of the notification's stored record CID. +type ListedNotification struct { + ID int64 + Reason Reason + RecordURI string + ActorDID string + SubjectURI string + RootPostURI string + RecordCreatedAt time.Time + SortAt time.Time + IsRead bool + RootPost ListedReference + Subject ListedReference + Record ListedReference + UpvoteCount int + RecentUpvoterDIDs []string +} + +// ListedReference is one referenced item's read-time state and current CID. +// A deleted or moderator-removed reference is rendered without lookup content. +type ListedReference struct { + State ReferenceState + CID string +} + +// ListPage is one page of listed notifications. Cursor is "" on the last page; +// SeenAt is nil when the recipient has no stored seen_at. +type ListPage struct { + Notifications []ListedNotification + Cursor string + SeenAt *time.Time +} + +// Preferences are a recipient's per-reason notification settings (true = enabled). +type Preferences struct { + PostReply bool + CommentReply bool + Mention bool + Upvote bool +} + +// PreferencesUpdate is a partial preferences change; nil leaves a reason unchanged. +type PreferencesUpdate struct { + PostReply *bool + CommentReply *bool + Mention *bool + Upvote *bool +} + +// ErrAccountNotIndexed means the caller has no users row, so notification +// state cannot be stored for it. +var ErrAccountNotIndexed = errors.New("account is not indexed") + +// PreferencesRepository stores preferences in notification_state.disabled_reasons. +type PreferencesRepository interface { + GetPreferences(ctx context.Context, did string) (Preferences, error) + PutPreferences(ctx context.Context, did string, update PreferencesUpdate) (Preferences, error) +} diff --git a/internal/core/notifications/list_service.go b/internal/core/notifications/list_service.go new file mode 100644 index 0000000..1f4f0bc --- /dev/null +++ b/internal/core/notifications/list_service.go @@ -0,0 +1,432 @@ +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "Coves/internal/core/blobs" + "Coves/internal/core/comments" + "Coves/internal/core/embeds" + "Coves/internal/core/posts" + "Coves/internal/core/users" + + "github.com/rivo/uniseg" +) + +// ProfileLookup batch-loads indexed profiles. +type ProfileLookup interface { + GetByDIDs(ctx context.Context, dids []string) (map[string]*users.User, error) +} + +// PostViewLookup batch-loads post views. +type PostViewLookup interface { + GetViewsByURIs(ctx context.Context, uris []string, viewerDID string) (map[string]*posts.PostView, error) +} + +// CommentLookup batch-loads comments. +type CommentLookup interface { + GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) +} + +// ListService serves listNotifications. +type ListService interface { + ListNotifications(ctx context.Context, recipientDID, cursor string, limit int) (ListNotificationsOutput, error) +} + +type listService struct { + repo ReadRepository + profiles ProfileLookup + postViews PostViewLookup + commentLookup CommentLookup +} + +// NewListService builds the listNotifications service. +func NewListService(repo ReadRepository, profiles ProfileLookup, postViews PostViewLookup, commentLookup CommentLookup) ListService { + return listService{repo: repo, profiles: profiles, postViews: postViews, commentLookup: commentLookup} +} + +func (s listService) ListNotifications(ctx context.Context, recipientDID, cursor string, limit int) (ListNotificationsOutput, error) { + page, err := s.repo.List(ctx, recipientDID, cursor, limit) + if err != nil { + return ListNotificationsOutput{}, fmt.Errorf("list notifications: %w", err) + } + output := ListNotificationsOutput{Notifications: make([]NotificationView, 0, len(page.Notifications)), Cursor: page.Cursor} + if page.SeenAt != nil { + output.SeenAt = notificationTime(*page.SeenAt) + } + + var actorDIDs, postURIs, commentURIs []string + actors, postsSeen, commentsSeen := make(map[string]bool), make(map[string]bool), make(map[string]bool) + add := func(key string, seen map[string]bool, keys *[]string) { + if key != "" && !seen[key] { + seen[key] = true + *keys = append(*keys, key) + } + } + for _, row := range page.Notifications { + if row.Reason == ReasonUpvote { + for _, voterDID := range row.RecentUpvoterDIDs { + add(voterDID, actors, &actorDIDs) + } + } else { + add(row.ActorDID, actors, &actorDIDs) + } + if row.RootPost.State == ReferenceLive { + add(row.RootPostURI, postsSeen, &postURIs) + } + switch row.Reason { + case ReasonUpvote: + if row.Subject.State == ReferenceLive { + if posts.IsPostCollection(posts.CollectionOfPostURI(row.SubjectURI)) { + add(row.SubjectURI, postsSeen, &postURIs) + } else { + add(row.SubjectURI, commentsSeen, &commentURIs) + } + } + case ReasonPostReply: + if row.Record.State == ReferenceLive { + add(row.RecordURI, commentsSeen, &commentURIs) + } + if row.Subject.State == ReferenceLive { + add(row.SubjectURI, postsSeen, &postURIs) + } + case ReasonCommentReply: + if row.Record.State == ReferenceLive { + add(row.RecordURI, commentsSeen, &commentURIs) + } + if row.Subject.State == ReferenceLive { + add(row.SubjectURI, commentsSeen, &commentURIs) + } + case ReasonMention: + if row.Record.State == ReferenceLive { + if posts.IsPostCollection(posts.CollectionOfPostURI(row.RecordURI)) { + add(row.RecordURI, postsSeen, &postURIs) + } else { + add(row.RecordURI, commentsSeen, &commentURIs) + } + } + } + } + + var profiles map[string]*users.User + if len(actorDIDs) > 0 { + profiles, err = s.profiles.GetByDIDs(ctx, actorDIDs) + if err != nil { + return ListNotificationsOutput{}, fmt.Errorf("load notification authors: %w", err) + } + } + var postViews map[string]*posts.PostView + if len(postURIs) > 0 { + postViews, err = s.postViews.GetViewsByURIs(ctx, postURIs, "") + if err != nil { + return ListNotificationsOutput{}, fmt.Errorf("load notification posts: %w", err) + } + } + thumbnails := make(map[string]struct{ url, alt string }, len(postViews)) + for uri, post := range postViews { + url, alt := posts.PreviewThumbnail(post) + thumbnails[uri] = struct{ url, alt string }{url, alt} + } + var commentViews map[string]*comments.Comment + if len(commentURIs) > 0 { + commentViews, err = s.commentLookup.GetByURIsBatch(ctx, commentURIs) + if err != nil { + return ListNotificationsOutput{}, fmt.Errorf("load notification comments: %w", err) + } + } + + var omissions omittedRows + for _, row := range page.Notifications { + record, root := commentViews[row.RecordURI], postViews[row.RootPostURI] + postMention := row.Reason == ReasonMention && posts.IsPostCollection(posts.CollectionOfPostURI(row.RecordURI)) + switch { + case row.Reason != ReasonUpvote && row.Record.State == ReferenceLive && postMention && postViews[row.RecordURI] == nil: + omissions.add(row.Reason, &omissions.missingRecord) + continue + case row.Reason != ReasonUpvote && row.Record.State == ReferenceLive && !postMention && record == nil: + omissions.add(row.Reason, &omissions.missingRecord) + continue + case row.Reason != ReasonUpvote && row.Record.State == ReferenceLive && !postMention && record.DeletedAt != nil: + omissions.add(row.Reason, &omissions.deletedRecord) + continue + case row.RootPost.State == ReferenceLive && root == nil: + omissions.add(row.Reason, &omissions.missingRoot) + continue + } + var recordLabels *posts.SelfLabels + if row.Reason != ReasonUpvote && row.Record.State == ReferenceLive && !postMention { + if recordLabels, err = notificationCommentLabels(record); err != nil { + omissions.add(row.Reason, &omissions.malformedLabels) + continue + } + } + var subject *SubjectView + switch row.Reason { + case ReasonUpvote: + subject = &SubjectView{URI: row.SubjectURI, CID: row.Subject.CID, Status: referenceStatus(row.Subject.State)} + if row.Subject.State == ReferenceLive { + if posts.IsPostCollection(posts.CollectionOfPostURI(row.SubjectURI)) { + post := postViews[row.SubjectURI] + if post == nil { + omissions.add(row.Reason, &omissions.missingSubject) + continue + } + subject.Preview = notificationPostPreview(post) + subject.Labels = notificationPostLabels(post) + subject.Thumbnail, subject.ThumbnailAlt = thumbnails[row.SubjectURI].url, thumbnails[row.SubjectURI].alt + } else { + comment := commentViews[row.SubjectURI] + if comment == nil { + omissions.add(row.Reason, &omissions.missingSubject) + continue + } + if comment.DeletedAt != nil { + omissions.add(row.Reason, &omissions.deletedSubject) + continue + } + if subject.Labels, err = notificationCommentLabels(comment); err != nil { + omissions.add(row.Reason, &omissions.malformedLabels) + continue + } + subject.Preview = notificationExcerpt(comment.Content) + } + } + case ReasonPostReply: + subject = &SubjectView{URI: row.SubjectURI, CID: row.Subject.CID, Status: referenceStatus(row.Subject.State)} + if row.Subject.State == ReferenceLive { + post := postViews[row.SubjectURI] + if post == nil { + omissions.add(row.Reason, &omissions.missingSubject) + continue + } + subject.Preview = notificationPostPreview(post) + subject.Labels = notificationPostLabels(post) + subject.Thumbnail, subject.ThumbnailAlt = thumbnails[row.SubjectURI].url, thumbnails[row.SubjectURI].alt + } + case ReasonCommentReply: + subject = &SubjectView{URI: row.SubjectURI, CID: row.Subject.CID, Status: referenceStatus(row.Subject.State)} + if row.Subject.State == ReferenceLive { + comment := commentViews[row.SubjectURI] + if comment == nil { + omissions.add(row.Reason, &omissions.missingSubject) + continue + } + if comment.DeletedAt != nil { + omissions.add(row.Reason, &omissions.deletedSubject) + continue + } + if subject.Labels, err = notificationCommentLabels(comment); err != nil { + omissions.add(row.Reason, &omissions.malformedLabels) + continue + } + subject.Preview = notificationExcerpt(comment.Content) + } + case ReasonMention: + // Mentions have no subject reference. + default: + omissions.add(row.Reason, &omissions.unrecognizedReason) + continue + } + + rootView := &RootPostView{URI: row.RootPostURI, CID: row.RootPost.CID, Status: referenceStatus(row.RootPost.State)} + if row.RootPost.State == ReferenceLive { + if title := postRecordField(root, "title"); strings.TrimSpace(title) != "" { + rootView.Title = title + } + rootView.Community = root.Community + rootView.Labels = notificationPostLabels(root) + rootView.Thumbnail, rootView.ThumbnailAlt = thumbnails[row.RootPostURI].url, thumbnails[row.RootPostURI].alt + } + view := NotificationView{Reason: row.Reason, SortAt: notificationTime(row.SortAt), IsRead: row.IsRead, + RootPost: rootView, Subject: subject} + if row.Reason == ReasonUpvote { + view.UpvoteCount = row.UpvoteCount + for _, voterDID := range row.RecentUpvoterDIDs { + view.RecentUpvoters = append(view.RecentUpvoters, notificationProfileView(voterDID, profiles)) + } + } else { + author := notificationProfileView(row.ActorDID, profiles) + view.Author = &author + recordView := &RecordView{URI: row.RecordURI, CID: row.Record.CID, Status: referenceStatus(row.Record.State), CreatedAt: notificationTime(row.RecordCreatedAt)} + if row.Record.State == ReferenceLive { + if postMention { + post := postViews[row.RecordURI] + text, _ := notificationPostText(post) + recordView.Excerpt = notificationExcerpt(text) + recordView.Labels = notificationPostLabels(post) + recordView.Thumbnail, recordView.ThumbnailAlt = thumbnails[row.RecordURI].url, thumbnails[row.RecordURI].alt + } else { + recordView.Excerpt = notificationExcerpt(record.Content) + recordView.Labels = recordLabels + } + } + view.Record = recordView + } + output.Notifications = append(output.Notifications, view) + } + omissions.log(ctx) + return output, nil +} + +func notificationProfileView(did string, profiles map[string]*users.User) ProfileView { + view := ProfileView{DID: did} + if profile := profiles[did]; profile != nil { + view.Handle = profile.Handle + if profile.DisplayName != "" { + view.DisplayName = &profile.DisplayName + } + if avatarURL := blobs.HydrateImageURL(blobs.GetImageURLConfig(), profile.PDSURL, profile.DID, profile.AvatarCID, "avatar_small"); avatarURL != "" { + view.Avatar = &avatarURL + } + } + return view +} + +func referenceStatus(state ReferenceState) string { + switch state { + case ReferenceDeleted: + return "deleted" + case ReferenceRemovedByModerator: + return "removedByModerator" + case ReferenceRemovedByServerAdmin: + return "removedByServerAdmin" + default: + return "" + } +} + +// omittedRows counts listed rows dropped during hydration. It holds counts +// only, so its log line never carries URIs, DIDs or cursors. +type omittedRows struct { + total, postReply, commentReply, otherReason int + missingRecord, deletedRecord, missingRoot, missingSubject, deletedSubject, unrecognizedReason int + malformedLabels int +} + +func (o *omittedRows) add(reason Reason, cause *int) { + o.total++ + *cause++ + switch reason { + case ReasonPostReply: + o.postReply++ + case ReasonCommentReply: + o.commentReply++ + default: + o.otherReason++ + } +} + +func (o omittedRows) log(ctx context.Context) { + if o.total == 0 { + return + } + slog.WarnContext(ctx, "notifications: omitted unhydratable rows", + slog.Int("omitted", o.total), + slog.Group("by_reason", + slog.Int(string(ReasonPostReply), o.postReply), + slog.Int(string(ReasonCommentReply), o.commentReply), + slog.Int("other", o.otherReason)), + slog.Group("by_cause", + slog.Int("missing_record", o.missingRecord), + slog.Int("deleted_record", o.deletedRecord), + slog.Int("missing_root", o.missingRoot), + slog.Int("missing_subject", o.missingSubject), + slog.Int("deleted_subject", o.deletedSubject), + slog.Int("unrecognized_reason", o.unrecognizedReason), + slog.Int("malformed_labels", o.malformedLabels))) +} + +func notificationTime(at time.Time) string { return at.UTC().Format(time.RFC3339Nano) } + +func postRecordField(post *posts.PostView, field string) string { + record, ok := post.Record.(map[string]interface{}) + if !ok { + return "" + } + value, _ := record[field].(string) + return value +} + +// notificationPostText chooses the same source for subjects and post mentions. +// Callers apply their own excerpt rule to the selected text. +func notificationPostText(post *posts.PostView) (text string, isTitle bool) { + if title := postRecordField(post, "title"); strings.TrimSpace(title) != "" { + return title, true + } + if embed, ok := post.Embed.(map[string]interface{}); ok { + if kind := embed["$type"]; kind == embeds.TypeImages || kind == embeds.TypeImages+"#view" { + return "", false + } + } + return postRecordField(post, "content"), false +} + +func notificationPostPreview(post *posts.PostView) string { + text, isTitle := notificationPostText(post) + if isTitle { + return text + } + return notificationExcerpt(text) +} + +func notificationPostLabels(post *posts.PostView) *posts.SelfLabels { + record, ok := post.Record.(map[string]interface{}) + if !ok { + return nil + } + switch labels := record["labels"].(type) { + case posts.SelfLabels: + return boundedNotificationLabels(&labels) + case *posts.SelfLabels: + return boundedNotificationLabels(labels) + default: + return nil + } +} + +// notificationCommentLabels returns an error when the stored self-labels do +// not decode; the caller omits the row rather than show it unlabelled. +func notificationCommentLabels(comment *comments.Comment) (*posts.SelfLabels, error) { + if comment.ContentLabels == nil { + return nil, nil + } + var labels posts.SelfLabels + if err := json.Unmarshal([]byte(*comment.ContentLabels), &labels); err != nil { + return nil, fmt.Errorf("decode comment self-labels: %w", err) + } + return boundedNotificationLabels(&labels), nil +} + +func boundedNotificationLabels(labels *posts.SelfLabels) *posts.SelfLabels { + if labels == nil || len(labels.Values) == 0 { + return nil + } + bounded := &posts.SelfLabels{Values: make([]posts.SelfLabel, 0, min(len(labels.Values), 10))} + for _, label := range labels.Values { + if len(label.Val) <= 128 { + bounded.Values = append(bounded.Values, label) + if len(bounded.Values) == 10 { + break + } + } + } + if len(bounded.Values) == 0 { + return nil + } + return bounded +} + +func notificationExcerpt(text string) string { + clusters := uniseg.NewGraphemes(text) + for count := 0; count < 140; count++ { + if !clusters.Next() { + return text + } + } + _, end := clusters.Positions() + return text[:end] +} diff --git a/internal/core/notifications/list_service_image_post_test.go b/internal/core/notifications/list_service_image_post_test.go new file mode 100644 index 0000000..aa44d60 --- /dev/null +++ b/internal/core/notifications/list_service_image_post_test.go @@ -0,0 +1,110 @@ +package notifications_test + +import ( + "encoding/json" + "strings" + "testing" + + "Coves/internal/core/blobs" + + "github.com/stretchr/testify/require" +) + +func TestListNotifications_ImagePostsAndAltText(t *testing.T) { + const imageURL = "https://img.example.test/img/content_preview/plain/did:plc:owner/bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" + const linkURL = "https://img.example.test/img/embed_thumbnail/plain/did:plc:owner/bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" + const legacyImageURL = "https://img.example.test/img/content_preview/plain/did:plc:community/bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" + buildImages := func(firstAlt interface{}) map[string]interface{} { + first := map[string]interface{}{"image": previewBlob(previewCID)} + if firstAlt != nil { + first["alt"] = firstAlt + } + return map[string]interface{}{"$type": "social.coves.embed.images", "images": []interface{}{ + first, + map[string]interface{}{"image": previewBlob(thirdPreviewCID), "alt": "second"}, + }} + } + buildLink := func() map[string]interface{} { + embed := previewLink(previewBlob(secondPreviewCID)) + external := embed["external"].(map[string]interface{}) + external["title"] = "Linked page title" + external["description"] = "Linked page description" + external["images"] = []interface{}{map[string]interface{}{"image": previewBlob(thirdPreviewCID), "alt": "gallery alt"}} + return embed + } + for _, tc := range []struct { + name, title, body, wantText, wantThumbnail, wantAlt string + build func() map[string]interface{} + unprojectable bool + }{ + {"untitled image has no body fallback", "", "Secret image body", "", imageURL, "A red kite", func() map[string]interface{} { return buildImages("A red kite") }, false}, + {"blank-titled image has no body fallback", " ", "Secret image body", "", imageURL, "A red kite", func() map[string]interface{} { return buildImages("A red kite") }, false}, + {"titled image shows title and thumbnail", "Sunset", "Body", "Sunset", imageURL, "A red kite", func() map[string]interface{} { return buildImages("A red kite") }, false}, + {"untitled link uses body and link thumb", "", "Linked article body", "Linked article body", linkURL, "", buildLink, false}, + {"first-image alt retained exactly across positions", "Sunset", "Body", "Sunset", imageURL, " A red kite over the ridge ", func() map[string]interface{} { return buildImages(" A red kite over the ridge ") }, false}, + {"absent alt does not use second image", "Sunset", "Body", "Sunset", imageURL, "", func() map[string]interface{} { return buildImages(nil) }, false}, + {"empty alt does not use second image", "Sunset", "Body", "Sunset", imageURL, "", func() map[string]interface{} { return buildImages("") }, false}, + {"ASCII-whitespace alt omitted", "Sunset", "Body", "Sunset", imageURL, "", func() map[string]interface{} { return buildImages(" \t\n") }, false}, + {"ideographic-space alt omitted", "Sunset", "Body", "Sunset", imageURL, "", func() map[string]interface{} { return buildImages(" ") }, false}, + {"numeric alt omitted", "Sunset", "Body", "Sunset", imageURL, "", func() map[string]interface{} { return buildImages(123) }, false}, + {"exactly 1000 ASCII alt characters", "Sunset", "Body", "Sunset", imageURL, strings.Repeat("a", 1000), func() map[string]interface{} { return buildImages(strings.Repeat("a", 1000)) }, false}, + {"1001 ASCII alt characters cut to 1000", "Sunset", "Body", "Sunset", imageURL, strings.Repeat("a", 1000), func() map[string]interface{} { return buildImages(strings.Repeat("a", 1001)) }, false}, + {"family emoji cut at 10000 bytes on cluster boundary", "Sunset", "Body", "Sunset", imageURL, strings.Repeat("👨‍👩‍👧‍👦", 400), func() map[string]interface{} { return buildImages(strings.Repeat("👨‍👩‍👧‍👦", 500)) }, false}, + {"prefix changes remaining byte budget without splitting cluster", "Sunset", "Body", "Sunset", imageURL, "a" + strings.Repeat("👨‍👩‍👧‍👦", 399), func() map[string]interface{} { + return buildImages("a" + strings.Repeat("👨‍👩‍👧‍👦", 500)) + }, false}, + {"bounded whitespace-only alt omitted", "Sunset", "Body", "Sunset", imageURL, "", func() map[string]interface{} { return buildImages(strings.Repeat(" ", 1000) + "x") }, false}, + {"untitled image alt is not text fallback", "", "Secret image body", "", imageURL, "A red kite", func() map[string]interface{} { return buildImages("A red kite") }, false}, + {"link card title description and gallery alt are not thumb alt", "", "Linked article body", "Linked article body", linkURL, "", buildLink, false}, + {"unprojectable untitled image has neither text nor alt", "", "Secret image body", "", "", "", func() map[string]interface{} { return previewImages("https://evil.example/x.jpg", "leaked alt") }, true}, + } { + t.Run(tc.name, func(t *testing.T) { + setPreviewImageConfig(t, blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test"}) + post := previewPostView("image", false, tc.build(), tc.title, tc.body) + var rows []map[string]json.RawMessage + var encoded string + if tc.unprojectable { + rows, encoded = previewPage(t, post, previewPostView("sibling", false, buildImages("Sibling alt"), "Sunset", "Body")) + assertPreviewMedia(t, rows, 3, imageURL, "Sibling alt") + require.NotContains(t, encoded, "leaked alt") + require.NotContains(t, encoded, "https://evil.example/x.jpg") + } else { + rows, encoded = previewPage(t, post) + } + assertPreviewMedia(t, rows, 0, tc.wantThumbnail, tc.wantAlt) + for _, position := range []struct { + index int + field, textKey string + }{ + {0, "rootPost", "title"}, {0, "subject", "preview"}, {1, "subject", "preview"}, {2, "record", "excerpt"}, + } { + reference := previewReference(t, rows[position.index], position.field) + wantText := tc.wantText + if position.field == "rootPost" { + wantText = tc.title + if strings.TrimSpace(wantText) == "" { + wantText = "" + } + } + assertPreviewField(t, reference, position.textKey, wantText) + } + if tc.title == "" && strings.HasPrefix(tc.name, "untitled image") { + require.NotContains(t, encoded, tc.body, "body must never become image-post text") + } + }) + } + t.Run("legacy first-image blob cid resolves under community owner", func(t *testing.T) { + setPreviewImageConfig(t, blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test"}) + legacyBlob := map[string]interface{}{"cid": thirdPreviewCID, "mimeType": "image/jpeg"} + rows, _ := previewPage(t, previewPostView("legacy", true, previewImages(legacyBlob, "Legacy alt"), "", "Body")) + assertPreviewMedia(t, rows, 0, legacyImageURL, "Legacy alt") + for _, position := range []struct { + index int + field, textKey string + }{ + {0, "rootPost", "title"}, {0, "subject", "preview"}, {1, "subject", "preview"}, {2, "record", "excerpt"}, + } { + assertPreviewField(t, previewReference(t, rows[position.index], position.field), position.textKey, "") + } + }) +} diff --git a/internal/core/notifications/list_service_labels_test.go b/internal/core/notifications/list_service_labels_test.go new file mode 100644 index 0000000..26f281b --- /dev/null +++ b/internal/core/notifications/list_service_labels_test.go @@ -0,0 +1,233 @@ +package notifications_test + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "testing" + "time" + + "Coves/internal/core/comments" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + + "github.com/stretchr/testify/require" +) + +func TestListNotifications_LabelRules(t *testing.T) { + const rootURI = "at://did:plc:owner/social.coves.community.postv2/root" + const commentURI = "at://did:plc:actor/social.coves.community.comment/reply" + const recordPostURI = "at://did:plc:actor/social.coves.community.postv2/mention" + const tenUnknown = `{"values":[{"val":"unknown-1"},{"val":"unknown-2"},{"val":"unknown-3"},{"val":"unknown-4"},{"val":"unknown-5"},{"val":"unknown-6"},{"val":"unknown-7"},{"val":"unknown-8"},{"val":"unknown-9"},{"val":"unknown-10"}]}` + longValue := strings.Repeat("x", 200) + shortBoundary := strings.Repeat("é", 64) + trueValue := true + cases := []struct { + name, rootLabels, commentLabels string + postLabels *posts.SelfLabels + reason notifications.Reason + wantRoot, wantSubject, wantRecord string + }{ + {name: "unlabeled reply inherits nothing", reason: notifications.ReasonCommentReply, rootLabels: `{"values":[{"val":"nsfw"}]}`, wantRoot: `{"values":[{"val":"nsfw"}]}`}, + {name: "comment drops long value then keeps first ten", reason: notifications.ReasonPostReply, + commentLabels: `{"values":[{"val":"nsfw"},{"val":"gore"},{"val":"` + longValue + `"},{"val":"one"},{"val":"two"},{"val":"three"},{"val":"four"},{"val":"five"},{"val":"six"},{"val":"seven"},{"val":"eight"},{"val":"nine"}]}`, + wantRecord: `{"values":[{"val":"nsfw"},{"val":"gore"},{"val":"one"},{"val":"two"},{"val":"three"},{"val":"four"},{"val":"five"},{"val":"six"},{"val":"seven"},{"val":"eight"}]}`}, + {name: "ten unknown post labels exclude nsfw", reason: notifications.ReasonMention, + postLabels: &posts.SelfLabels{Values: []posts.SelfLabel{{Val: "unknown-1"}, {Val: "unknown-2"}, {Val: "unknown-3"}, {Val: "unknown-4"}, {Val: "unknown-5"}, {Val: "unknown-6"}, {Val: "unknown-7"}, {Val: "unknown-8"}, {Val: "unknown-9"}, {Val: "unknown-10"}, {Val: "nsfw"}}}, wantRecord: tenUnknown}, + {name: "128 UTF-8 bytes retained and 129 dropped", reason: notifications.ReasonPostReply, + commentLabels: `{"values":[{"val":"` + shortBoundary + `"},{"val":"` + shortBoundary + `a"}]}`, + wantRecord: `{"values":[{"val":"` + shortBoundary + `"}]}`}, + {name: "negated post label is preserved", reason: notifications.ReasonMention, + postLabels: &posts.SelfLabels{Values: []posts.SelfLabel{{Val: "spoiler", Neg: &trueValue}}}, wantRecord: `{"values":[{"val":"spoiler","neg":true}]}`}, + {name: "empty post labels omitted", reason: notifications.ReasonMention, postLabels: &posts.SelfLabels{Values: []posts.SelfLabel{}}}, + {name: "empty comment labels omitted", reason: notifications.ReasonPostReply, commentLabels: `{"values":[]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + rootLabels := &posts.SelfLabels{} + storedRoot := tc.rootLabels + if storedRoot == "" { + storedRoot = `{"values":[{"val":"nsfw"}]}` + } + require.NoError(t, json.Unmarshal([]byte(storedRoot), rootLabels)) + root := &posts.PostView{URI: rootURI, CID: "bafyroot", Record: map[string]interface{}{"title": "Root"}} + root.Record.(map[string]interface{})["labels"] = *rootLabels + comment := &comments.Comment{URI: commentURI, CID: "bafycomment", Content: "Reply"} + if tc.commentLabels != "" { + comment.ContentLabels = &tc.commentLabels + } + post := &posts.PostView{URI: recordPostURI, CID: "bafypost", Record: map[string]interface{}{"title": "Mention"}} + if tc.postLabels != nil { + post.Record.(map[string]interface{})["labels"] = *tc.postLabels + } + row := notifications.ListedNotification{Reason: tc.reason, ActorDID: "did:plc:actor", RootPostURI: rootURI, RootPost: notifications.ListedReference{CID: "bafyroot"}, RecordURI: commentURI, Record: notifications.ListedReference{CID: "bafycomment"}, SubjectURI: rootURI, Subject: notifications.ListedReference{CID: "bafyroot"}, RecordCreatedAt: at, SortAt: at} + if tc.reason == notifications.ReasonCommentReply { + row.SubjectURI = "at://did:plc:owner/social.coves.community.comment/parent" + row.Subject.CID = "bafyparent" + } + if tc.reason == notifications.ReasonMention { + row.RecordURI, row.Record.CID = recordPostURI, "bafypost" + row.SubjectURI = "" + } + if tc.wantRoot == "" { + tc.wantRoot = `{"values":[{"val":"nsfw"}]}` + } + if tc.reason == notifications.ReasonPostReply && tc.wantSubject == "" { + tc.wantSubject = `{"values":[{"val":"nsfw"}]}` + } + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: []notifications.ListedNotification{row}}}, cannedProfiles{}, anonymousPosts{rootURI: root, recordPostURI: post}, cannedComments{commentURI: comment, row.SubjectURI: {URI: row.SubjectURI, CID: "bafyparent", Content: "Parent"}}) + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 1, "invalid labels must not lose the notification") + encoded, err := json.Marshal(got) + require.NoError(t, err) + var output struct { + Notifications []map[string]json.RawMessage `json:"notifications"` + } + require.NoError(t, json.Unmarshal(encoded, &output)) + for _, field := range []struct{ name, want string }{{"rootPost", tc.wantRoot}, {"subject", tc.wantSubject}, {"record", tc.wantRecord}} { + if field.name == "subject" && tc.reason == notifications.ReasonMention { + require.NotContains(t, output.Notifications[0], "subject") + continue + } + var reference map[string]json.RawMessage + require.NoError(t, json.Unmarshal(output.Notifications[0][field.name], &reference)) + if field.want == "" { + require.NotContains(t, reference, "labels", field.name) + } else { + require.Contains(t, reference, "labels", field.name) + require.JSONEq(t, field.want, string(reference["labels"]), field.name) + } + } + }) + } +} + +// A live comment whose stored self-labels do not decode must not be shown +// with its labels silently dropped: the row is omitted and counted, while +// valid neighbours and placeholder references (never parsed) stay listed. +func TestListNotifications_MalformedLiveCommentLabelsOmitRow(t *testing.T) { + const rootURI = "at://did:plc:owner/social.coves.community.postv2/root" + const neighbourURI = "at://did:plc:actor/social.coves.community.comment/neighbour" + const malformedURI = "at://did:plc:actor/social.coves.community.comment/malformed" + const replyURI = "at://did:plc:actor/social.coves.community.comment/reply" + const placeholderRecordURI = "at://did:plc:actor/social.coves.community.comment/removed" + const placeholderParentURI = "at://did:plc:owner/social.coves.community.comment/deleted" + const actorDID = "did:plc:actor" + const validLabels = `{"values":[{"val":"nsfw"}]}` + const wrongShape = `{"values":"bad"}` + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + live := func(cid string) notifications.ListedReference { + return notifications.ListedReference{State: notifications.ReferenceLive, CID: cid} + } + labels := func(raw string) *string { return &raw } + root := &posts.PostView{URI: rootURI, CID: "bafyroot", Record: map[string]interface{}{"title": "Root"}} + + cases := []struct { + name string + target notifications.ListedNotification + wantCause map[string]int64 + }{ + { + name: "postReply record comment", + target: notifications.ListedNotification{Reason: notifications.ReasonPostReply, ActorDID: actorDID, RootPostURI: rootURI, RootPost: live("bafyroot"), + SubjectURI: rootURI, Subject: live("bafyroot"), RecordURI: malformedURI, Record: live("bafymalformed")}, + wantCause: map[string]int64{"omitted": 1, "by_reason.postReply": 1, "by_reason.commentReply": 0, "by_reason.other": 0}, + }, + { + name: "comment mention record", + target: notifications.ListedNotification{Reason: notifications.ReasonMention, ActorDID: actorDID, RootPostURI: rootURI, RootPost: live("bafyroot"), + RecordURI: malformedURI, Record: live("bafymalformed")}, + wantCause: map[string]int64{"omitted": 1, "by_reason.postReply": 0, "by_reason.commentReply": 0, "by_reason.other": 1}, + }, + { + name: "commentReply subject comment", + target: notifications.ListedNotification{Reason: notifications.ReasonCommentReply, ActorDID: actorDID, RootPostURI: rootURI, RootPost: live("bafyroot"), + SubjectURI: malformedURI, Subject: live("bafymalformed"), RecordURI: replyURI, Record: live("bafyreply")}, + wantCause: map[string]int64{"omitted": 1, "by_reason.postReply": 0, "by_reason.commentReply": 1, "by_reason.other": 0}, + }, + { + name: "comment upvote subject", + target: notifications.ListedNotification{Reason: notifications.ReasonUpvote, RootPostURI: rootURI, RootPost: live("bafyroot"), + SubjectURI: malformedURI, Subject: live("bafymalformed"), UpvoteCount: 1, RecentUpvoterDIDs: []string{actorDID}}, + wantCause: map[string]int64{"omitted": 1, "by_reason.postReply": 0, "by_reason.commentReply": 0, "by_reason.other": 1}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + captured := captureDefaultLogs(t) + neighbour := notifications.ListedNotification{Reason: notifications.ReasonPostReply, ActorDID: actorDID, RootPostURI: rootURI, RootPost: live("bafyroot"), + SubjectURI: rootURI, Subject: live("bafyroot"), RecordURI: neighbourURI, Record: live("bafyneighbour"), RecordCreatedAt: at, SortAt: at.Add(time.Minute)} + target := tc.target + target.RecordCreatedAt, target.SortAt = at, at + placeholder := notifications.ListedNotification{Reason: notifications.ReasonCommentReply, ActorDID: actorDID, RootPostURI: rootURI, RootPost: live("bafyroot"), + SubjectURI: placeholderParentURI, Subject: notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafyparentdeleted"}, + RecordURI: placeholderRecordURI, Record: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyrecordremoved"}, + RecordCreatedAt: at, SortAt: at.Add(-time.Minute)} + commentViews := cannedComments{ + neighbourURI: {URI: neighbourURI, CID: "bafyneighbour", Content: "Neighbour", ContentLabels: labels(validLabels)}, + malformedURI: {URI: malformedURI, CID: "bafymalformed", Content: "Malformed", ContentLabels: labels(wrongShape)}, + replyURI: {URI: replyURI, CID: "bafyreply", Content: "Reply"}, + placeholderRecordURI: {URI: placeholderRecordURI, CID: "bafyrecordremoved", Content: "Must not leak", ContentLabels: labels(wrongShape)}, + placeholderParentURI: {URI: placeholderParentURI, CID: "bafyparentdeleted", Content: "Must not leak", ContentLabels: labels(wrongShape)}, + } + page := notifications.ListPage{Notifications: []notifications.ListedNotification{neighbour, target, placeholder}} + service := notifications.NewListService(cannedListReader{page: page}, cannedProfiles{}, anonymousPosts{rootURI: root}, commentViews) + + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + + listed := make([]string, 0, len(got.Notifications)) + for _, view := range got.Notifications { + if view.Record == nil { + listed = append(listed, string(view.Reason)+" subject "+view.Subject.URI) + continue + } + listed = append(listed, string(view.Reason)+" "+view.Record.URI) + } + require.Equal(t, []string{"postReply " + neighbourURI, "commentReply " + placeholderRecordURI}, listed, + "the malformed live row must be omitted; the valid neighbour and the placeholder must stay listed") + neighbourLabels, err := json.Marshal(got.Notifications[0].Record.Labels) + require.NoError(t, err) + require.JSONEq(t, validLabels, string(neighbourLabels)) + require.Equal(t, "removedByModerator", got.Notifications[1].Record.Status) + require.Equal(t, "deleted", got.Notifications[1].Subject.Status) + + var omission *slog.Record + for _, record := range captured() { + if record.Message == "notifications: omitted unhydratable rows" { + require.Nil(t, omission, "expected one omission warning per page") + record := record + omission = &record + } + } + require.NotNil(t, omission, "the malformed row must be counted in the omission warning") + attrs := []slog.Attr{} + omission.Attrs(func(attr slog.Attr) bool { + attrs = append(attrs, attr) + return true + }) + flat := map[string]slog.Value{} + flattenLogAttrs("", attrs, flat) + counts := map[string]int64{} + for key, value := range flat { + counts[key] = value.Int64() + } + want := map[string]int64{ + "by_cause.missing_record": 0, + "by_cause.deleted_record": 0, + "by_cause.missing_root": 0, + "by_cause.missing_subject": 0, + "by_cause.deleted_subject": 0, + "by_cause.unrecognized_reason": 0, + "by_cause.malformed_labels": 1, + } + for key, value := range tc.wantCause { + want[key] = value + } + require.Equal(t, want, counts) + }) + } +} diff --git a/internal/core/notifications/list_service_mention_test.go b/internal/core/notifications/list_service_mention_test.go new file mode 100644 index 0000000..d268895 --- /dev/null +++ b/internal/core/notifications/list_service_mention_test.go @@ -0,0 +1,79 @@ +package notifications_test + +import ( + "context" + "encoding/json" + "log/slog" + "testing" + "time" + + "Coves/internal/core/notifications" + + "github.com/stretchr/testify/require" +) + +func TestListNotifications_MentionHydrationOmissions(t *testing.T) { + const rootURI = "at://did:plc:owner/social.coves.community.postv2/root" + const missingPost = "at://did:plc:actor/social.coves.community.postv2/missing" + const livePost = "at://did:plc:actor/social.coves.community.postv2/live" + const deletedComment = "at://did:plc:actor/social.coves.community.comment/deleted" + const missingComment = "at://did:plc:actor/social.coves.community.comment/missing" + const replyURI = "at://did:plc:actor/social.coves.community.comment/reply" + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + deletedAt := at.Add(time.Minute) + live := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedroot"} + rows := []struct { + reason notifications.Reason + uri, root string + record, rootReference notifications.ListedReference + }{ + {notifications.ReasonMention, missingPost, missingPost, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedmissing"}, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedmissingroot"}}, + {notifications.ReasonMention, livePost, livePost, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedpost"}, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedpostroot"}}, + {notifications.ReasonMention, deletedComment, rootURI, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylisteddeleted"}, live}, + {notifications.ReasonMention, missingComment, rootURI, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedcomment"}, live}, + {notifications.ReasonPostReply, replyURI, rootURI, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedreply"}, live}, + } + listed := make([]notifications.ListedNotification, 0, len(rows)) + for index, row := range rows { + item := notifications.ListedNotification{Reason: row.reason, ActorDID: "did:plc:actor", RecordURI: row.uri, RootPostURI: row.root, Record: row.record, RootPost: row.rootReference, RecordCreatedAt: at, SortAt: at.Add(time.Duration(index) * time.Second)} + if row.reason == notifications.ReasonPostReply { + item.SubjectURI, item.Subject = rootURI, live + } + listed = append(listed, item) + } + logs := captureDefaultLogs(t) + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: listed}}, cannedProfiles{}, anonymousPosts{ + rootURI: {URI: rootURI, CID: "bafylookuproot", Record: map[string]interface{}{"title": "Root"}}, + livePost: {URI: livePost, CID: "bafylookuppost", Record: map[string]interface{}{"title": "Post title"}}, + }, cannedComments{ + deletedComment: {URI: deletedComment, CID: "bafylookupdeleted", Content: "Private", DeletedAt: &deletedAt}, + replyURI: {URI: replyURI, CID: "bafylookupreply", Content: "Reply"}, + }) + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 2, "only the three unhydratable mentions are omitted") + var uris []string + for _, notification := range got.Notifications { + uris = append(uris, notification.Record.URI) + } + require.Equal(t, []string{livePost, replyURI}, uris) + for _, reference := range []struct { + value any + want string + }{ + {got.Notifications[0].Record, `{"uri":"` + livePost + `","cid":"bafylistedpost","createdAt":"2026-09-20T09:00:00Z","excerpt":"Post title"}`}, + {got.Notifications[0].RootPost, `{"uri":"` + livePost + `","cid":"bafylistedpostroot","title":"Post title"}`}, + } { + encoded, err := json.Marshal(reference.value) + require.NoError(t, err) + require.JSONEq(t, reference.want, string(encoded)) + } + require.Len(t, logs(), 1) + require.Equal(t, slog.LevelWarn, logs()[0].Level) + attrs := map[string]slog.Value{} + logs()[0].Attrs(func(attr slog.Attr) bool { flattenLogAttrs("", []slog.Attr{attr}, attrs); return true }) + require.Equal(t, int64(3), attrs["omitted"].Int64()) + require.Equal(t, int64(0), attrs["by_cause.unrecognized_reason"].Int64()) + require.Equal(t, int64(2), attrs["by_cause.missing_record"].Int64()) + require.Equal(t, int64(1), attrs["by_cause.deleted_record"].Int64()) +} diff --git a/internal/core/notifications/list_service_placeholder_test.go b/internal/core/notifications/list_service_placeholder_test.go new file mode 100644 index 0000000..662b5c4 --- /dev/null +++ b/internal/core/notifications/list_service_placeholder_test.go @@ -0,0 +1,115 @@ +package notifications_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "Coves/internal/core/comments" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/core/users" + + "github.com/stretchr/testify/require" +) + +type recordingPlaceholderPosts struct { + views anonymousPosts + requested []string +} + +func (p *recordingPlaceholderPosts) GetViewsByURIs(_ context.Context, uris []string, viewer string) (map[string]*posts.PostView, error) { + p.requested = append(p.requested, uris...) + if viewer != "" { + return nil, nil + } + return p.views, nil +} + +type recordingPlaceholderComments struct { + views cannedComments + requested []string +} + +func (c *recordingPlaceholderComments) GetByURIsBatch(_ context.Context, uris []string) (map[string]*comments.Comment, error) { + c.requested = append(c.requested, uris...) + return c.views, nil +} + +func TestListNotifications_PlaceholderReferences(t *testing.T) { + const rootURI = "at://did:plc:owner/social.coves.community.postv2/root" + const parentURI = "at://did:plc:owner/social.coves.community.comment/parent" + const replyURI = "at://did:plc:actor/social.coves.community.comment/reply" + const actorDID = "did:plc:actor" + at := time.Date(2026, 9, 20, 9, 0, 0, 123456000, time.UTC) + deletedAt := at + live := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylisedroot"} + parentLive := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedparent"} + replyLive := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylistedreply"} + post := &posts.PostView{URI: rootURI, CID: "bafylookuproot", Record: map[string]interface{}{"title": "Root title", "content": "Private body"}, Community: &posts.CommunityRef{DID: "did:plc:community", Handle: "community.coves.social", Name: "Community"}} + parent := &comments.Comment{URI: parentURI, CID: "bafylookupparent", Content: "Parent preview"} + reply := &comments.Comment{URI: replyURI, CID: "bafylookupreply", Content: "Reply excerpt"} + rootLiveJSON := `{"uri":"` + rootURI + `","cid":"bafylisedroot","title":"Root title","community":{"did":"did:plc:community","handle":"community.coves.social","name":"Community"}}` + subjectLiveJSON := `{"uri":"` + rootURI + `","cid":"bafylisedroot","preview":"Root title"}` + parentLiveJSON := `{"uri":"` + parentURI + `","cid":"bafylistedparent","preview":"Parent preview"}` + recordLiveJSON := `{"uri":"` + replyURI + `","cid":"bafylistedreply","excerpt":"Reply excerpt","createdAt":"2026-09-20T09:00:00.123456Z"}` + rootDeletedJSON := `{"uri":"` + rootURI + `","cid":"bafylistedrootdeleted","status":"deleted"}` + rootRemovedJSON := `{"uri":"` + rootURI + `","cid":"bafylistedrootremoved","status":"removedByModerator"}` + parentDeletedJSON := `{"uri":"` + parentURI + `","cid":"bafylistedparentdeleted","status":"deleted"}` + recordDeletedJSON := `{"uri":"` + replyURI + `","cid":"bafylistedreplydeleted","createdAt":"2026-09-20T09:00:00.123456Z","status":"deleted"}` + adminRemovedRoot := notifications.ListedReference{State: notifications.ReferenceRemovedByServerAdmin, CID: "bafylistedrootadmin"} + rootAdminRemovedJSON := `{"uri":"` + rootURI + `","cid":"bafylistedrootadmin","status":"removedByServerAdmin"}` + parentAdminRemovedJSON := `{"uri":"` + parentURI + `","cid":"bafylistedparentadmin","status":"removedByServerAdmin"}` + recordAdminRemovedJSON := `{"uri":"` + replyURI + `","cid":"bafylistedreplyadmin","createdAt":"2026-09-20T09:00:00.123456Z","status":"removedByServerAdmin"}` + cases := []struct { + name string + reason notifications.Reason + root, subject, record notifications.ListedReference + posts anonymousPosts + comments cannedComments + wantRoot, wantSubject, wantRecord string + wantPosts, wantComments []string + }{ + {"missing deleted record", notifications.ReasonPostReply, live, live, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafylistedreplydeleted"}, anonymousPosts{rootURI: post}, cannedComments{}, rootLiveJSON, subjectLiveJSON, recordDeletedJSON, []string{rootURI}, nil}, + {"missing deleted parent and removed root", notifications.ReasonCommentReply, notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafylistedrootremoved"}, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafylistedparentdeleted"}, replyLive, anonymousPosts{}, cannedComments{replyURI: reply}, rootRemovedJSON, parentDeletedJSON, recordLiveJSON, nil, []string{replyURI}}, + {"missing deleted subject and root post", notifications.ReasonPostReply, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafylistedrootdeleted"}, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafylistedrootdeleted"}, replyLive, anonymousPosts{}, cannedComments{replyURI: reply}, rootDeletedJSON, `{"uri":"` + rootURI + `","cid":"bafylistedrootdeleted","status":"deleted"}`, recordLiveJSON, nil, []string{replyURI}}, + {"deleted record returned by comment lookup", notifications.ReasonPostReply, live, live, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafylistedreplydeleted"}, anonymousPosts{rootURI: post}, cannedComments{replyURI: {URI: replyURI, CID: "bafyleakedreply", Content: "Must not leak", DeletedAt: &deletedAt}}, rootLiveJSON, subjectLiveJSON, recordDeletedJSON, []string{rootURI}, nil}, + {"deleted subject returned by comment lookup", notifications.ReasonCommentReply, live, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafylistedparentdeleted"}, replyLive, anonymousPosts{rootURI: post}, cannedComments{parentURI: {URI: parentURI, CID: "bafyleakedparent", Content: "Must not leak", DeletedAt: &deletedAt}, replyURI: reply}, rootLiveJSON, parentDeletedJSON, recordLiveJSON, []string{rootURI}, []string{replyURI}}, + {"deleted post returned by post lookup", notifications.ReasonPostReply, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafylistedrootdeleted"}, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafylistedrootdeleted"}, replyLive, anonymousPosts{rootURI: post}, cannedComments{replyURI: reply}, rootDeletedJSON, `{"uri":"` + rootURI + `","cid":"bafylistedrootdeleted","status":"deleted"}`, recordLiveJSON, nil, []string{replyURI}}, + {"server admin removed root and subject post with live record", notifications.ReasonPostReply, adminRemovedRoot, adminRemovedRoot, replyLive, anonymousPosts{rootURI: post}, cannedComments{replyURI: reply}, rootAdminRemovedJSON, `{"uri":"` + rootURI + `","cid":"bafylistedrootadmin","status":"removedByServerAdmin"}`, recordLiveJSON, nil, []string{replyURI}}, + {"server admin removed subject comment and record with live root", notifications.ReasonCommentReply, live, notifications.ListedReference{State: notifications.ReferenceRemovedByServerAdmin, CID: "bafylistedparentadmin"}, notifications.ListedReference{State: notifications.ReferenceRemovedByServerAdmin, CID: "bafylistedreplyadmin"}, anonymousPosts{rootURI: post}, cannedComments{parentURI: {URI: parentURI, CID: "bafyleakedparent", Content: "Must not leak"}, replyURI: {URI: replyURI, CID: "bafyleakedreply", Content: "Must not leak"}}, rootLiveJSON, parentAdminRemovedJSON, recordAdminRemovedJSON, []string{rootURI}, nil}, + {"live reference CIDs come from List", notifications.ReasonCommentReply, live, parentLive, replyLive, anonymousPosts{rootURI: post}, cannedComments{parentURI: parent, replyURI: reply}, rootLiveJSON, parentLiveJSON, recordLiveJSON, []string{rootURI}, []string{replyURI, parentURI}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + subjectURI := rootURI + if tc.reason == notifications.ReasonCommentReply { + subjectURI = parentURI + } + row := notifications.ListedNotification{Reason: tc.reason, ActorDID: actorDID, RootPostURI: rootURI, SubjectURI: subjectURI, RecordURI: replyURI, RootPost: tc.root, Subject: tc.subject, Record: tc.record, RecordCreatedAt: at, SortAt: at} + postLookup := &recordingPlaceholderPosts{views: tc.posts} + commentLookup := &recordingPlaceholderComments{views: tc.comments} + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: []notifications.ListedNotification{row}}}, cannedProfiles{actorDID: &users.User{DID: actorDID, Handle: "actor.test"}}, postLookup, commentLookup) + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 1, "placeholder reply must remain listed") + require.Equal(t, ¬ifications.ProfileView{DID: actorDID, Handle: "actor.test"}, got.Notifications[0].Author) + for _, reference := range []struct { + name string + value any + want string + }{ + {"rootPost", got.Notifications[0].RootPost, tc.wantRoot}, + {"subject", got.Notifications[0].Subject, tc.wantSubject}, + {"record", got.Notifications[0].Record, tc.wantRecord}, + } { + encoded, err := json.Marshal(reference.value) + require.NoError(t, err) + require.JSONEq(t, reference.want, string(encoded), reference.name) + } + require.ElementsMatch(t, tc.wantPosts, postLookup.requested, "only live post URIs may be looked up") + require.ElementsMatch(t, tc.wantComments, commentLookup.requested, "only live comment URIs may be looked up") + }) + } +} diff --git a/internal/core/notifications/list_service_preview_placeholder_test.go b/internal/core/notifications/list_service_preview_placeholder_test.go new file mode 100644 index 0000000..eed6a08 --- /dev/null +++ b/internal/core/notifications/list_service_preview_placeholder_test.go @@ -0,0 +1,143 @@ +package notifications_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "Coves/internal/core/blobs" + "Coves/internal/core/comments" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + + "github.com/stretchr/testify/require" +) + +func TestListNotifications_PlaceholdersOmitLabelsAndThumbnail(t *testing.T) { + blobs.ResetImageURLConfigForTesting() + blobs.SetImageURLConfig(blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test"}) + t.Cleanup(blobs.ResetImageURLConfigForTesting) + const rootURI = "at://did:plc:owner/social.coves.community.postv2/root" + const subjectPostURI = "at://did:plc:owner/social.coves.community.postv2/subject" + const mentionPostURI = "at://did:plc:actor/social.coves.community.postv2/mention" + const subjectCommentURI = "at://did:plc:owner/social.coves.community.comment/parent" + const recordCommentURI = "at://did:plc:actor/social.coves.community.comment/reply" + const rootCID = "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" + const subjectCID = "bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" + const mentionCID = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" + type position struct{ name, labels, thumbnail, alt string } + root := position{"rootPost", `{"values":[{"val":"nsfw"}]}`, "https://img.example.test/img/content_preview/plain/did:plc:owner/bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", "Root image"} + postSubject := position{"subject", `{"values":[{"val":"spoiler"}]}`, "https://img.example.test/img/content_preview/plain/did:plc:owner/bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm", "Subject image"} + commentSubject := position{"subject", `{"values":[{"val":"gore"}]}`, "", ""} + commentRecord := position{"record", `{"values":[{"val":"violence"}]}`, "", ""} + postRecord := position{"record", `{"values":[{"val":"spoiler"}]}`, "https://img.example.test/img/content_preview/plain/did:plc:actor/bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", "Mention image"} + type scenario struct { + name, placeholder string + reason notifications.Reason + postSubject, postRecord bool + } + scenarios := []scenario{ + {"postReply rootPost", "rootPost", notifications.ReasonPostReply, true, false}, + {"postReply subject", "subject", notifications.ReasonPostReply, true, false}, + {"postReply record", "record", notifications.ReasonPostReply, true, false}, + {"commentReply rootPost", "rootPost", notifications.ReasonCommentReply, false, false}, + {"commentReply subject", "subject", notifications.ReasonCommentReply, false, false}, + {"commentReply record", "record", notifications.ReasonCommentReply, false, false}, + {"comment mention rootPost", "rootPost", notifications.ReasonMention, false, false}, + {"comment mention record", "record", notifications.ReasonMention, false, false}, + {"post mention rootPost", "rootPost", notifications.ReasonMention, false, true}, + {"post mention record", "record", notifications.ReasonMention, false, true}, + {"post upvote rootPost", "rootPost", notifications.ReasonUpvote, true, false}, + {"post upvote subject", "subject", notifications.ReasonUpvote, true, false}, + {"comment upvote rootPost", "rootPost", notifications.ReasonUpvote, false, false}, + {"comment upvote subject", "subject", notifications.ReasonUpvote, false, false}, + } + for _, state := range []struct { + name string + value notifications.ReferenceState + status string + }{{"deleted", notifications.ReferenceDeleted, "deleted"}, {"removedByModerator", notifications.ReferenceRemovedByModerator, "removedByModerator"}, {"removedByServerAdmin", notifications.ReferenceRemovedByServerAdmin, "removedByServerAdmin"}} { + for _, scenario := range scenarios { + t.Run(state.name+"/"+scenario.name, func(t *testing.T) { + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + post := func(uri, owner, imageCID, alt, label string) *posts.PostView { + return &posts.PostView{URI: uri, CID: imageCID, Author: &posts.AuthorView{DID: owner}, Community: &posts.CommunityRef{DID: "did:plc:community", Handle: "community.coves.social", Name: "Community"}, + Record: map[string]interface{}{"title": "Image", "labels": posts.SelfLabels{Values: []posts.SelfLabel{{Val: label}}}}, + Embed: map[string]interface{}{"$type": "social.coves.embed.images", "images": []interface{}{map[string]interface{}{"alt": alt, "image": map[string]interface{}{"$type": "blob", "ref": map[string]interface{}{"$link": imageCID}, "mimeType": "image/jpeg", "size": 123}}}}} + } + label := func(val string) *string { value := `{"values":[{"val":"` + val + `"}]}`; return &value } + postsByURI := anonymousPosts{ + rootURI: post(rootURI, "did:plc:owner", rootCID, "Root image", "nsfw"), + subjectPostURI: post(subjectPostURI, "did:plc:owner", subjectCID, "Subject image", "spoiler"), + mentionPostURI: post(mentionPostURI, "did:plc:actor", mentionCID, "Mention image", "spoiler"), + } + commentsByURI := cannedComments{ + subjectCommentURI: &comments.Comment{URI: subjectCommentURI, CID: "bafyparent", Content: "Parent", ContentLabels: label("gore")}, + recordCommentURI: &comments.Comment{URI: recordCommentURI, CID: "bafyreply", Content: "Reply", ContentLabels: label("violence")}, + } + row := notifications.ListedNotification{Reason: scenario.reason, ActorDID: "did:plc:actor", RootPostURI: rootURI, RootPost: notifications.ListedReference{CID: "bafyroot"}, RecordURI: recordCommentURI, Record: notifications.ListedReference{CID: "bafyreply"}, RecordCreatedAt: at, SortAt: at, UpvoteCount: 1} + expected := []position{root} + if scenario.reason == notifications.ReasonPostReply || scenario.reason == notifications.ReasonUpvote && scenario.postSubject { + row.SubjectURI, row.Subject.CID = subjectPostURI, "bafysubject" + expected = append(expected, postSubject) + } else if scenario.reason == notifications.ReasonCommentReply || scenario.reason == notifications.ReasonUpvote { + row.SubjectURI, row.Subject.CID = subjectCommentURI, "bafyparent" + expected = append(expected, commentSubject) + } + if scenario.postRecord { + row.RecordURI, row.Record.CID = mentionPostURI, "bafymention" + } + if scenario.reason != notifications.ReasonUpvote { + if scenario.postRecord { + expected = append(expected, postRecord) + } else { + expected = append(expected, commentRecord) + } + } + switch scenario.placeholder { + case "rootPost": + row.RootPost.State = state.value + case "subject": + row.Subject.State = state.value + case "record": + row.Record.State = state.value + } + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: []notifications.ListedNotification{row}}}, cannedProfiles{}, postsByURI, commentsByURI) + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 1) + encoded, err := json.Marshal(got) + require.NoError(t, err) + var page struct { + Notifications []map[string]json.RawMessage `json:"notifications"` + } + require.NoError(t, json.Unmarshal(encoded, &page)) + for _, sibling := range expected { + var reference map[string]json.RawMessage + require.NoError(t, json.Unmarshal(page.Notifications[0][sibling.name], &reference)) + if sibling.name == scenario.placeholder { + require.Contains(t, reference, "status", sibling.name) + require.JSONEq(t, `"`+state.status+`"`, string(reference["status"]), sibling.name) + for _, field := range []string{"labels", "thumbnail", "thumbnailAlt", "title", "community", "preview", "excerpt"} { + require.NotContains(t, reference, field, sibling.name) + } + continue + } + require.NotContains(t, reference, "status", sibling.name) + require.Contains(t, reference, "labels", sibling.name+" labels") + require.JSONEq(t, sibling.labels, string(reference["labels"]), sibling.name+" labels") + if sibling.thumbnail != "" { + require.Contains(t, reference, "thumbnail", sibling.name+" thumbnail") + require.Contains(t, reference, "thumbnailAlt", sibling.name+" thumbnailAlt") + require.JSONEq(t, `"`+sibling.thumbnail+`"`, string(reference["thumbnail"]), sibling.name+" thumbnail") + require.JSONEq(t, `"`+sibling.alt+`"`, string(reference["thumbnailAlt"]), sibling.name+" thumbnailAlt") + } else { + require.NotContains(t, reference, "thumbnail", sibling.name) + require.NotContains(t, reference, "thumbnailAlt", sibling.name) + } + } + }) + } + } +} diff --git a/internal/core/notifications/list_service_test.go b/internal/core/notifications/list_service_test.go new file mode 100644 index 0000000..f08be94 --- /dev/null +++ b/internal/core/notifications/list_service_test.go @@ -0,0 +1,229 @@ +package notifications_test + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "sync" + "testing" + "time" + + "Coves/internal/core/comments" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/core/users" + + "github.com/stretchr/testify/require" +) + +type cannedListReader struct { + notifications.ReadRepository + page notifications.ListPage +} + +func (r cannedListReader) List(context.Context, string, string, int) (notifications.ListPage, error) { + return r.page, nil +} + +type cannedProfiles map[string]*users.User + +func (p cannedProfiles) GetByDIDs(context.Context, []string) (map[string]*users.User, error) { + return p, nil +} + +type anonymousPosts map[string]*posts.PostView + +func (p anonymousPosts) GetViewsByURIs(_ context.Context, _ []string, viewerDID string) (map[string]*posts.PostView, error) { + if viewerDID != "" { + return map[string]*posts.PostView{}, nil + } + return p, nil +} + +type cannedComments map[string]*comments.Comment + +func (c cannedComments) GetByURIsBatch(context.Context, []string) (map[string]*comments.Comment, error) { + return c, nil +} + +func TestListNotifications_OmitsRowsWithUnavailableReferences(t *testing.T) { + seen := time.Date(2026, time.September, 20, 9, 0, 0, 500000000, time.UTC) + deleted := seen + const postURI = "at://did:plc:owner/social.coves.community.postv2/root" + const parentURI = "at://did:plc:owner/social.coves.community.comment/parent" + const actorDID = "did:plc:replier" + post := &posts.PostView{URI: postURI, CID: "bafycurrentroot", Record: map[string]interface{}{"title": "Root title", "content": "Root content"}, Community: &posts.CommunityRef{DID: "did:plc:community", Handle: "community.coves.social", Name: "Community"}} + baseComments := cannedComments{parentURI: {URI: parentURI, CID: "bafyparent", Content: "Parent reply"}} + basePosts := anonymousPosts{postURI: post} + for _, tc := range []struct { + name string + reason notifications.Reason + recordURI, subjectURI, rootURI string + posts anonymousPosts + comments cannedComments + }{ + {"missing record", notifications.ReasonPostReply, "at://did:plc:replier/social.coves.community.comment/missing", postURI, postURI, basePosts, baseComments}, + {"missing subject post", notifications.ReasonPostReply, "at://did:plc:replier/social.coves.community.comment/reply", "at://did:plc:owner/social.coves.community.postv2/missing", postURI, basePosts, baseComments}, + {"missing subject comment", notifications.ReasonCommentReply, "at://did:plc:replier/social.coves.community.comment/reply", "at://did:plc:owner/social.coves.community.comment/missing", postURI, basePosts, baseComments}, + {"missing root post", notifications.ReasonCommentReply, "at://did:plc:replier/social.coves.community.comment/reply", parentURI, "at://did:plc:owner/social.coves.community.postv2/missing", basePosts, baseComments}, + {"deleted record", notifications.ReasonPostReply, "at://did:plc:replier/social.coves.community.comment/deleted", postURI, postURI, basePosts, cannedComments{parentURI: baseComments[parentURI], "at://did:plc:replier/social.coves.community.comment/deleted": {URI: "at://did:plc:replier/social.coves.community.comment/deleted", CID: "bafydeleted", DeletedAt: &deleted}}}, + {"deleted subject comment", notifications.ReasonCommentReply, "at://did:plc:replier/social.coves.community.comment/reply", parentURI, postURI, basePosts, cannedComments{parentURI: {URI: parentURI, CID: "bafyparent", DeletedAt: &deleted}}}, + } { + t.Run(tc.name, func(t *testing.T) { + const healthyURI = "at://did:plc:replier/social.coves.community.comment/healthy" + const replyURI = "at://did:plc:replier/social.coves.community.comment/reply" + cs := cannedComments{healthyURI: {URI: healthyURI, CID: "bafyhealthy", Content: "Healthy reply"}, replyURI: {URI: replyURI, CID: "bafyreply", Content: "Reply"}} + for uri, comment := range tc.comments { + cs[uri] = comment + } + rows := []notifications.ListedNotification{ + {Reason: notifications.ReasonPostReply, RecordURI: healthyURI, SubjectURI: postURI, RootPostURI: postURI, ActorDID: actorDID, SortAt: seen.Add(time.Minute), RecordCreatedAt: seen}, + {Reason: tc.reason, RecordURI: tc.recordURI, SubjectURI: tc.subjectURI, RootPostURI: tc.rootURI, ActorDID: actorDID, SortAt: seen, RecordCreatedAt: seen}, + {Reason: notifications.ReasonPostReply, RecordURI: replyURI, SubjectURI: postURI, RootPostURI: postURI, ActorDID: actorDID, SortAt: seen.Add(-time.Minute), RecordCreatedAt: seen}, + } + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: rows, Cursor: "next-page", SeenAt: &seen}}, cannedProfiles{actorDID: {DID: actorDID, Handle: "replier.test"}}, tc.posts, cs) + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 2) + require.Equal(t, []string{healthyURI, replyURI}, []string{got.Notifications[0].Record.URI, got.Notifications[1].Record.URI}) + require.Equal(t, "next-page", got.Cursor) + require.Equal(t, "2026-09-20T09:00:00.5Z", got.SeenAt) + }) + } + t.Run("all omitted keeps page metadata and empty array", func(t *testing.T) { + row := notifications.ListedNotification{Reason: notifications.ReasonPostReply, RecordURI: "at://did:plc:replier/social.coves.community.comment/missing", SubjectURI: postURI, RootPostURI: postURI, ActorDID: actorDID, SortAt: seen} + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: []notifications.ListedNotification{row}, Cursor: "next-page", SeenAt: &seen}}, cannedProfiles{actorDID: {DID: actorDID, Handle: "replier.test"}}, basePosts, baseComments) + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + raw, err := json.Marshal(got) + require.NoError(t, err) + require.JSONEq(t, `{"notifications":[],"cursor":"next-page","seenAt":"2026-09-20T09:00:00.5Z"}`, string(raw)) + }) +} + +type recordingHandler struct { + mu *sync.Mutex + records *[]slog.Record +} + +func (h recordingHandler) Enabled(context.Context, slog.Level) bool { return true } + +func (h recordingHandler) Handle(_ context.Context, record slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + *h.records = append(*h.records, record.Clone()) + return nil +} + +func (h recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h recordingHandler) WithGroup(string) slog.Handler { return h } + +// captureDefaultLogs routes the default slog logger into a recorder for the +// rest of the test. Callers must not use t.Parallel. +func captureDefaultLogs(t *testing.T) func() []slog.Record { + t.Helper() + handler := recordingHandler{mu: &sync.Mutex{}, records: &[]slog.Record{}} + previous := slog.Default() + slog.SetDefault(slog.New(handler)) + t.Cleanup(func() { slog.SetDefault(previous) }) + return func() []slog.Record { + handler.mu.Lock() + defer handler.mu.Unlock() + return append([]slog.Record(nil), *handler.records...) + } +} + +func flattenLogAttrs(prefix string, attrs []slog.Attr, into map[string]slog.Value) { + for _, attr := range attrs { + value := attr.Value.Resolve() + if value.Kind() == slog.KindGroup { + flattenLogAttrs(prefix+attr.Key+".", value.Group(), into) + continue + } + into[prefix+attr.Key] = value + } +} + +func TestListNotifications_LogsOmittedRowCountsWithoutIdentifiers(t *testing.T) { + seen := time.Date(2026, time.September, 20, 9, 0, 0, 0, time.UTC) + deleted := seen + const postURI = "at://did:plc:owner/social.coves.community.postv2/root" + const parentURI = "at://did:plc:owner/social.coves.community.comment/parent" + const healthyURI = "at://did:plc:replier/social.coves.community.comment/healthy" + const replyURI = "at://did:plc:replier/social.coves.community.comment/reply" + const actorDID = "did:plc:replier" + post := &posts.PostView{URI: postURI, CID: "bafyroot", Record: map[string]interface{}{"title": "Root title"}} + profiles := cannedProfiles{actorDID: {DID: actorDID, Handle: "replier.test"}} + healthy := notifications.ListedNotification{Reason: notifications.ReasonPostReply, RecordURI: healthyURI, SubjectURI: postURI, RootPostURI: postURI, ActorDID: actorDID, SortAt: seen, RecordCreatedAt: seen} + + t.Run("omitted rows produce one warning with counts only", func(t *testing.T) { + captured := captureDefaultLogs(t) + rows := []notifications.ListedNotification{ + healthy, + {Reason: notifications.ReasonPostReply, RecordURI: "at://did:plc:replier/social.coves.community.comment/missing", SubjectURI: postURI, RootPostURI: postURI, ActorDID: actorDID, SortAt: seen, RecordCreatedAt: seen}, + {Reason: notifications.ReasonCommentReply, RecordURI: replyURI, SubjectURI: parentURI, RootPostURI: postURI, ActorDID: actorDID, SortAt: seen, RecordCreatedAt: seen}, + } + commentViews := cannedComments{ + healthyURI: {URI: healthyURI, CID: "bafyhealthy", Content: "Healthy reply"}, + replyURI: {URI: replyURI, CID: "bafyreply", Content: "Reply"}, + parentURI: {URI: parentURI, CID: "bafyparent", DeletedAt: &deleted}, + } + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: rows, Cursor: "next-page"}}, profiles, anonymousPosts{postURI: post}, commentViews) + + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "cursor-in", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 1) + + records := captured() + require.Len(t, records, 1, "expected exactly one log record for a page with omitted rows") + record := records[0] + require.Equal(t, slog.LevelWarn, record.Level) + attrs := []slog.Attr{} + record.Attrs(func(attr slog.Attr) bool { + attrs = append(attrs, attr) + return true + }) + flat := map[string]slog.Value{} + flattenLogAttrs("", attrs, flat) + counts := map[string]int64{} + for key, value := range flat { + require.Equal(t, slog.KindInt64, value.Kind(), "attribute %q must be a count", key) + counts[key] = value.Int64() + } + require.Equal(t, map[string]int64{ + "omitted": 2, + "by_reason.postReply": 1, + "by_reason.commentReply": 1, + "by_reason.other": 0, + "by_cause.missing_record": 1, + "by_cause.deleted_record": 0, + "by_cause.missing_root": 0, + "by_cause.missing_subject": 0, + "by_cause.deleted_subject": 1, + "by_cause.unrecognized_reason": 0, + "by_cause.malformed_labels": 0, + }, counts) + for _, text := range append([]string{record.Message}, func() []string { + values := []string{} + for key, value := range flat { + values = append(values, key, value.String()) + } + return values + }()...) { + require.NotContains(t, text, "at://") + require.NotContains(t, text, "did:") + require.False(t, strings.Contains(text, "cursor-in") || strings.Contains(text, "next-page"), "log must not carry cursors: %q", text) + } + }) + + t.Run("page without omissions logs nothing", func(t *testing.T) { + captured := captureDefaultLogs(t) + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: []notifications.ListedNotification{healthy}}}, profiles, anonymousPosts{postURI: post}, cannedComments{healthyURI: {URI: healthyURI, CID: "bafyhealthy", Content: "Healthy reply"}}) + + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 1) + require.Empty(t, captured()) + }) +} diff --git a/internal/core/notifications/list_service_thumbnail_test.go b/internal/core/notifications/list_service_thumbnail_test.go new file mode 100644 index 0000000..6211c87 --- /dev/null +++ b/internal/core/notifications/list_service_thumbnail_test.go @@ -0,0 +1,329 @@ +package notifications_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "Coves/internal/core/blobs" + "Coves/internal/core/comments" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +const previewCID = "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" +const secondPreviewCID = "bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" +const thirdPreviewCID = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" +const previewPDSURL = "https://pds.example.test" +const previewGetBlobURL = "https://pds.example.test/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aowner&cid=bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" + +// previewPostView builds a fresh stored view. Legacy blobs belong to the +// community repository; postv2 blobs belong to the author repository. +func previewPostView(rkey string, legacy bool, embed map[string]interface{}, title, body string) *posts.PostView { + view := &posts.PostView{CID: thirdPreviewCID, Embed: embed, Record: map[string]interface{}{"title": title, "content": body}, + Community: &posts.CommunityRef{DID: "did:plc:community", Handle: "community.test", Name: "Community", PDSURL: previewPDSURL}} + if legacy { + view.URI = "at://did:plc:community/social.coves.community.post/" + rkey + } else { + view.URI = "at://did:plc:owner/social.coves.community.postv2/" + rkey + view.Author = &posts.AuthorView{DID: "did:plc:owner", Handle: "owner.test", PDSURL: previewPDSURL} + } + return view +} + +func previewBlob(cid string) map[string]interface{} { + return map[string]interface{}{"$type": "blob", "ref": map[string]interface{}{"$link": cid}, "mimeType": "image/jpeg", "size": 1234} +} + +func previewLink(thumb interface{}) map[string]interface{} { + return map[string]interface{}{"$type": "social.coves.embed.external", "external": map[string]interface{}{"uri": "https://article.example.test/story", "title": "Story", "thumb": thumb}} +} + +func previewImages(image interface{}, alt interface{}) map[string]interface{} { + return map[string]interface{}{"$type": "social.coves.embed.images", "images": []interface{}{map[string]interface{}{"image": image, "alt": alt}}} +} + +// Each view occupies a reply, an upvote, and a post mention. The same URI is +// both root and subject on the first two, and root and record on the mention. +func previewPage(t *testing.T, views ...*posts.PostView) ([]map[string]json.RawMessage, string) { + t.Helper() + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + rows := make([]notifications.ListedNotification, 0, len(views)*3) + lookup := anonymousPosts{} + commentLookup := cannedComments{} + for index, view := range views { + lookup[view.URI] = view + replyURI := "at://did:plc:actor/social.coves.community.comment/reply" + string(rune('a'+index)) + commentLookup[replyURI] = &comments.Comment{URI: replyURI, CID: "bafyreply", Content: "Reply"} + reference := notifications.ListedReference{CID: view.CID} + rows = append(rows, + notifications.ListedNotification{Reason: notifications.ReasonPostReply, RootPostURI: view.URI, RootPost: reference, SubjectURI: view.URI, Subject: reference, RecordURI: replyURI, Record: notifications.ListedReference{CID: "bafyreply"}, ActorDID: "did:plc:actor", SortAt: at, RecordCreatedAt: at}, + notifications.ListedNotification{Reason: notifications.ReasonUpvote, RootPostURI: view.URI, RootPost: reference, SubjectURI: view.URI, Subject: reference, UpvoteCount: 1, SortAt: at}, + notifications.ListedNotification{Reason: notifications.ReasonMention, RootPostURI: view.URI, RootPost: reference, RecordURI: view.URI, Record: reference, ActorDID: "did:plc:actor", SortAt: at, RecordCreatedAt: at}, + ) + } + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: rows}}, cannedProfiles{}, lookup, commentLookup) + output, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, output.Notifications, len(rows), "every row remains listed") + encoded, err := json.Marshal(output) + require.NoError(t, err) + validatePreviewOutput(t, encoded) + var page struct { + Notifications []map[string]json.RawMessage `json:"notifications"` + } + require.NoError(t, json.Unmarshal(encoded, &page)) + return page.Notifications, string(encoded) +} + +// Indigo validates records rather than query outputs: wrap the actual output +// schema as a temporary record exactly as placeholderListLexicon does. +func validatePreviewOutput(t *testing.T, encoded []byte) { + t.Helper() + directory := filepath.Join("..", "..", "atproto", "lexicon") + catalog := lexicon.NewBaseCatalog() + require.NoError(t, catalog.LoadDirectory(directory)) + raw, err := os.ReadFile(filepath.Join(directory, "social", "coves", "notification", "listNotifications.json")) + require.NoError(t, err) + var document struct { + Defs struct { + Main struct { + Output struct { + Schema json.RawMessage `json:"schema"` + } `json:"output"` + } `json:"main"` + } `json:"defs"` + } + require.NoError(t, json.Unmarshal(raw, &document)) + var output lexicon.SchemaObject + require.NoError(t, json.Unmarshal(document.Defs.Main.Output.Schema, &output)) + const recordID = "test.coves.notification.previewListOutput" + require.NoError(t, catalog.AddSchemaFile(lexicon.SchemaFile{Lexicon: 1, ID: recordID, Defs: map[string]lexicon.SchemaDef{ + "main": {Inner: lexicon.SchemaRecord{Type: "record", Key: "any", Record: output}}, + }})) + data, err := atdata.UnmarshalJSON(encoded) + require.NoError(t, err) + data["$type"] = recordID + require.NoError(t, lexicon.ValidateRecord(catalog, data, recordID, 0), "listNotifications#output must validate") +} + +func previewReference(t *testing.T, row map[string]json.RawMessage, field string) map[string]json.RawMessage { + t.Helper() + var reference map[string]json.RawMessage + require.NoError(t, json.Unmarshal(row[field], &reference), field) + return reference +} + +func assertPreviewField(t *testing.T, reference map[string]json.RawMessage, field, expected string) { + t.Helper() + raw, present := reference[field] + if expected == "" { + require.False(t, present, "%s must be absent", field) + return + } + require.True(t, present, "%s must contain %q", field, expected) + var value string + require.NoError(t, json.Unmarshal(raw, &value)) + require.Equal(t, expected, value, field) +} + +func assertPreviewMedia(t *testing.T, rows []map[string]json.RawMessage, offset int, thumbnail, alt string) { + t.Helper() + for _, position := range []struct { + index int + field string + }{ + {offset, "rootPost"}, {offset, "subject"}, {offset + 1, "rootPost"}, {offset + 1, "subject"}, + {offset + 2, "rootPost"}, {offset + 2, "record"}, + } { + reference := previewReference(t, rows[position.index], position.field) + assertPreviewField(t, reference, "thumbnail", thumbnail) + assertPreviewField(t, reference, "thumbnailAlt", alt) + } +} + +func setPreviewImageConfig(t *testing.T, config blobs.ImageURLConfig) { + t.Helper() + blobs.ResetImageURLConfigForTesting() + blobs.SetImageURLConfig(config) + t.Cleanup(blobs.ResetImageURLConfigForTesting) +} + +func TestListNotifications_NoThumbnailWithoutValidatedProxiedBlob(t *testing.T) { + const validURL = "https://img.example.test/img/embed_thumbnail/plain/did:plc:owner/bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" + const imageURL = "https://img.example.test/img/content_preview/plain/did:plc:owner/bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" + const attackerURL = "https://evil.example/x.jpg" + oversized := strings.Repeat("a", 9000) + valid := func() *posts.PostView { + return previewPostView("valid", false, previewLink(previewBlob(previewCID)), "Valid", "Body") + } + cases := []struct { + name string + build func() *posts.PostView + mediaStrings []string + }{ + {"video still", func() *posts.PostView { + return previewPostView("bad", false, map[string]interface{}{"$type": "social.coves.embed.video", "video": previewBlob(previewCID), "thumbnail": previewBlob(secondPreviewCID)}, "Video", "Body") + }, nil}, + {"quoted post", func() *posts.PostView { + return previewPostView("bad", false, map[string]interface{}{"$type": "social.coves.embed.post", "post": map[string]interface{}{"uri": "at://did:plc:owner/social.coves.community.postv2/quoted"}}, "Quote", "Body") + }, nil}, + {"external gallery without thumb", func() *posts.PostView { + e := previewLink(nil) + delete(e["external"].(map[string]interface{}), "thumb") + e["external"].(map[string]interface{})["images"] = []interface{}{map[string]interface{}{"image": previewBlob(secondPreviewCID), "alt": "leaked alt"}} + return previewPostView("bad", false, e, "Gallery", "Body") + }, nil}, + {"postv2 with no author", func() *posts.PostView { + e := previewImages(previewBlob(secondPreviewCID), "leaked alt") + first := e["images"].([]interface{})[0].(map[string]interface{}) + first["thumb"], first["fullsize"] = attackerURL, attackerURL + p := previewPostView("bad", false, e, "Image", "Body") + p.Author = nil + return p + }, []string{attackerURL}}, + {"string thumb not a URI", func() *posts.PostView { return previewPostView("bad", false, previewLink("not a URI"), "Link", "Body") }, []string{"not a URI"}}, + {"string thumb direct getBlob", func() *posts.PostView { + return previewPostView("bad", false, previewLink(previewGetBlobURL), "Link", "Body") + }, []string{previewGetBlobURL}}, + {"string thumb attacker URL", func() *posts.PostView { return previewPostView("bad", false, previewLink(attackerURL), "Link", "Body") }, []string{attackerURL}}, + {"string first image", func() *posts.PostView { + return previewPostView("bad", false, previewImages(attackerURL, "leaked alt"), "Image", "Body") + }, []string{attackerURL}}, + {"forged external view", func() *posts.PostView { + e := previewLink(attackerURL) + e["$type"] = "social.coves.embed.external#view" + return previewPostView("bad", false, e, "Link", "Body") + }, []string{attackerURL}}, + {"forged images view attacker URL", func() *posts.PostView { + return previewPostView("bad", false, map[string]interface{}{"$type": "social.coves.embed.images#view", "images": []interface{}{map[string]interface{}{"thumb": attackerURL, "fullsize": attackerURL, "alt": "leaked alt"}}}, "Image", "Body") + }, []string{attackerURL}}, + {"forged images view getBlob URL", func() *posts.PostView { + return previewPostView("bad", false, map[string]interface{}{"$type": "social.coves.embed.images#view", "images": []interface{}{map[string]interface{}{"thumb": previewGetBlobURL, "fullsize": previewGetBlobURL, "alt": "leaked alt"}}}, "Image", "Body") + }, []string{previewGetBlobURL}}, + {"valid first image malformed second", func() *posts.PostView { + e := previewImages(previewBlob(secondPreviewCID), "leaked alt") + first := e["images"].([]interface{})[0].(map[string]interface{}) + first["thumb"], first["fullsize"] = attackerURL, attackerURL + e["images"] = append(e["images"].([]interface{}), "malformed second") + return previewPostView("bad", false, e, "Image", "Body") + }, []string{"malformed second", attackerURL}}, + } + for _, shape := range []string{"external", "images"} { + for _, cidCase := range []struct { + name, cid string + legacy bool + }{ + {"malformed link", "not a cid!", false}, {"short non CID link", "abcdefgh12", false}, + {"oversized link", oversized, false}, {"oversized legacy cid", oversized, true}, + // Valid CIDs in multibases the image proxy's CID check rejects. + {"base64url multibase legacy cid", "uAVUSIOOwxEKY_BwUmvv0yJlvuSQnrkHkZJuTTKSVmRt4UrhV", true}, + {"base64 multibase legacy cid", "mAVUSIJ3+/mHdduo9yuUCOICwg3nVet8gSC1v2+J1kon2R2d7", true}, + } { + shape, cidCase := shape, cidCase + cases = append(cases, struct { + name string + build func() *posts.PostView + mediaStrings []string + }{ + name: shape + " " + cidCase.name, + build: func() *posts.PostView { + var blob interface{} = previewBlob(cidCase.cid) + if cidCase.legacy { + blob = map[string]interface{}{"cid": cidCase.cid, "mimeType": "image/jpeg"} + } + if shape == "external" { + return previewPostView("bad", false, previewLink(blob), "Link", "Body") + } + return previewPostView("bad", false, previewImages(blob, "leaked alt"), "Image", "Body") + }, + mediaStrings: []string{cidCase.cid}, + }) + } + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setPreviewImageConfig(t, blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test"}) + rows, encoded := previewPage(t, tc.build(), valid()) + assertPreviewMedia(t, rows, 0, "", "") + require.NotContains(t, encoded, "leaked alt") + for _, media := range tc.mediaStrings { + require.NotContains(t, encoded, media) + } + require.NotContains(t, encoded, `"$link"`) + require.NotContains(t, encoded, `"mimeType"`) + assertPreviewMedia(t, rows, 3, validURL, "") + }) + } + t.Run("valid legacy cid is a positive image control", func(t *testing.T) { + setPreviewImageConfig(t, blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test"}) + rows, _ := previewPage(t, previewPostView("legacyblob", false, previewImages(map[string]interface{}{"cid": secondPreviewCID, "mimeType": "image/jpeg"}, "Legacy alt"), "", "Body")) + assertPreviewMedia(t, rows, 0, imageURL, "Legacy alt") + }) + t.Run("one image is both root and subject", func(t *testing.T) { + setPreviewImageConfig(t, blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test"}) + rows, _ := previewPage(t, previewPostView("shared", false, previewImages(previewBlob(secondPreviewCID), "Shared alt"), "", "Body")) + assertPreviewMedia(t, rows, 0, imageURL, "Shared alt") + }) + t.Run("proxy configurations", func(t *testing.T) { + const linkURL = "https://img.example.test/img/embed_thumbnail/plain/did:plc:owner/bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" + const imageURL = "https://img.example.test/img/content_preview/plain/did:plc:owner/bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" + build := func() []*posts.PostView { + return []*posts.PostView{ + previewPostView("link", false, previewLink(previewBlob(previewCID)), "Link", "Body"), + previewPostView("image", false, previewImages(previewBlob(secondPreviewCID), "A red kite"), "", "Body"), + } + } + const cdnLinkURL = "https://cdn.example.test/img/embed_thumbnail/plain/did:plc:owner/bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy" + const cdnImageURL = "https://cdn.example.test/img/content_preview/plain/did:plc:owner/bafkreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" + for _, tc := range []struct { + name string + config blobs.ImageURLConfig + linkURL, imageURL string + }{ + {"CDN only", blobs.ImageURLConfig{ProxyEnabled: true, CDNURL: "https://cdn.example.test"}, cdnLinkURL, cdnImageURL}, + {"host-less CDN overrides valid proxy base", blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test", CDNURL: "http://"}, "", ""}, + {"disabled", blobs.ImageURLConfig{}, "", ""}, + {"disabled with proxy base configured", blobs.ImageURLConfig{ProxyEnabled: false, ProxyBaseURL: "https://img.example.test"}, "", ""}, + {"empty proxy and CDN bases", blobs.ImageURLConfig{ProxyEnabled: true}, "", ""}, + {"non HTTP absolute URL", blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "ftp://img.example.test"}, "", ""}, + {"HTTP URL without host", blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "http://"}, "", ""}, + {"projected URL above URI length limit", blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test/" + strings.Repeat("a", 8200)}, "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.name == "disabled" { + setPreviewImageConfig(t, blobs.ImageURLConfig{}) + feedView := previewPostView("link", false, previewLink(previewBlob(previewCID)), "Link", "Body") + posts.TransformBlobRefsToURLs(feedView) + thumb := feedView.Embed.(map[string]interface{})["external"].(map[string]interface{})["thumb"] + require.Equal(t, previewGetBlobURL, thumb, "feed still serves the direct blob URL") + } + setPreviewImageConfig(t, blobs.ImageURLConfig{ProxyEnabled: true, ProxyBaseURL: "https://img.example.test"}) + control, _ := previewPage(t, build()...) + assertPreviewMedia(t, control, 0, linkURL, "") + assertPreviewMedia(t, control, 3, imageURL, "A red kite") + setPreviewImageConfig(t, tc.config) + rows, encoded := previewPage(t, build()...) + if tc.linkURL != "" { + assertPreviewMedia(t, rows, 0, tc.linkURL, "") + assertPreviewMedia(t, rows, 3, tc.imageURL, "A red kite") + return + } + assertPreviewMedia(t, rows, 0, "", "") + assertPreviewMedia(t, rows, 3, "", "") + require.NotContains(t, encoded, "A red kite") + require.NotContains(t, encoded, previewGetBlobURL) + require.NotContains(t, encoded, `"$link"`) + require.NotContains(t, encoded, `"mimeType"`) + }) + } + }) +} diff --git a/internal/core/notifications/list_service_titles_test.go b/internal/core/notifications/list_service_titles_test.go new file mode 100644 index 0000000..8903ccc --- /dev/null +++ b/internal/core/notifications/list_service_titles_test.go @@ -0,0 +1,92 @@ +package notifications_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "Coves/internal/core/comments" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + + "github.com/stretchr/testify/require" +) + +func TestListNotifications_BlankPostTitles(t *testing.T) { + const bodyExcerpt = "ABCDEFGHIJABCDEFGHIJ" + + "ABCDEFGHIJABCDEFGHIJ" + + "ABCDEFGHIJABCDEFGHIJ" + + "ABCDEFGHIJABCDEFGHIJ" + + "ABCDEFGHIJABCDEFGHIJ" + + "ABCDEFGHIJABCDEFGHIJ" + + "ABCDEFGHIJABCDEFGHIJ" + const titleExcerpt = "abcdefghijabcdefghij" + + "abcdefghijabcdefghij" + + "abcdefghijabcdefghij" + + "abcdefghijabcdefghij" + + "abcdefghijabcdefghij" + + "abcdefghijabcdefghij" + + "abcdefghijabcdefghij" + const wholeTitle = titleExcerpt + "zzzzzzzzzzzzzzzzzzzz" + + "zzzzzzzzzzzzzzzzzzzz" + "zzzzzzzzzzzzzzzzzzzz" + const postURI = "at://did:plc:owner/social.coves.community.postv2/subject" + const replyURI = "at://did:plc:actor/social.coves.community.comment/reply" + for _, tc := range []struct { + name, title, body, wantRoot, wantSubject, wantExcerpt string + }{ + {"ASCII spaces", " ", bodyExcerpt + "BODY-TAIL", "", bodyExcerpt, bodyExcerpt}, + {"tab and newline", "\t\n", bodyExcerpt + "BODY-TAIL", "", bodyExcerpt, bodyExcerpt}, + {"ideographic space", " ", bodyExcerpt + "BODY-TAIL", "", bodyExcerpt, bodyExcerpt}, + {"nonblank title retains surrounding whitespace", " Hello ", "Body", " Hello ", " Hello ", " Hello "}, + {"long title is whole except mention excerpt", titleExcerpt + strings.Repeat("z", 60), "Body", wholeTitle, wholeTitle, titleExcerpt}, + {"blank title and empty body omit text", " ", "", "", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + post := &posts.PostView{URI: postURI, CID: "bafypost", Record: map[string]interface{}{"title": tc.title, "content": tc.body}} + rows := []notifications.ListedNotification{ + {Reason: notifications.ReasonPostReply, RootPostURI: postURI, RootPost: notifications.ListedReference{CID: "bafypost"}, SubjectURI: postURI, Subject: notifications.ListedReference{CID: "bafypost"}, RecordURI: replyURI, Record: notifications.ListedReference{CID: "bafyreply"}, ActorDID: "did:plc:actor", SortAt: at, RecordCreatedAt: at}, + {Reason: notifications.ReasonUpvote, RootPostURI: postURI, RootPost: notifications.ListedReference{CID: "bafypost"}, SubjectURI: postURI, Subject: notifications.ListedReference{CID: "bafypost"}, SortAt: at, UpvoteCount: 1}, + {Reason: notifications.ReasonMention, RootPostURI: postURI, RootPost: notifications.ListedReference{CID: "bafypost"}, RecordURI: postURI, Record: notifications.ListedReference{CID: "bafypost"}, ActorDID: "did:plc:actor", SortAt: at, RecordCreatedAt: at}, + } + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: rows}}, cannedProfiles{}, anonymousPosts{postURI: post}, cannedComments{replyURI: &comments.Comment{URI: replyURI, CID: "bafyreply", Content: "Reply"}}) + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 3) + encoded, err := json.Marshal(got) + require.NoError(t, err) + var page struct { + Notifications []map[string]json.RawMessage `json:"notifications"` + } + require.NoError(t, json.Unmarshal(encoded, &page)) + for index, row := range page.Notifications { + var root map[string]json.RawMessage + require.NoError(t, json.Unmarshal(row["rootPost"], &root)) + assertPreviewText(t, root, "title", tc.wantRoot) + if index < 2 { + var subject map[string]json.RawMessage + require.NoError(t, json.Unmarshal(row["subject"], &subject)) + assertPreviewText(t, subject, "preview", tc.wantSubject) + } else { + var record map[string]json.RawMessage + require.NoError(t, json.Unmarshal(row["record"], &record)) + assertPreviewText(t, record, "excerpt", tc.wantExcerpt) + } + } + }) + } +} + +func assertPreviewText(t *testing.T, reference map[string]json.RawMessage, field, want string) { + t.Helper() + if want == "" { + require.NotContains(t, reference, field) + return + } + require.Contains(t, reference, field) + var value string + require.NoError(t, json.Unmarshal(reference[field], &value)) + require.Equal(t, want, value, field) +} diff --git a/internal/core/notifications/list_service_upvote_test.go b/internal/core/notifications/list_service_upvote_test.go new file mode 100644 index 0000000..b5170e5 --- /dev/null +++ b/internal/core/notifications/list_service_upvote_test.go @@ -0,0 +1,78 @@ +package notifications_test + +import ( + "context" + "log/slog" + "testing" + "time" + + "Coves/internal/core/notifications" + + "github.com/stretchr/testify/require" +) + +func TestListNotifications_UpvoteHydrationOmissions(t *testing.T) { + const root = "at://did:plc:owner/social.coves.community.postv2/root" + const present = "at://did:plc:owner/social.coves.community.postv2/present" + const missingPost = "at://did:plc:owner/social.coves.community.postv2/missing" + const missingComment = "at://did:plc:owner/social.coves.community.comment/missing" + const deletedLive = "at://did:plc:owner/social.coves.community.comment/deletedlive" + const missingRoot = "at://did:plc:owner/social.coves.community.postv2/missingroot" + const orphan = "at://did:plc:owner/social.coves.community.comment/orphan" + const presentComment = "at://did:plc:owner/social.coves.community.comment/presentcomment" + const placeholder = "at://did:plc:owner/social.coves.community.comment/placeholder" + const reply = "at://did:plc:actor/social.coves.community.comment/reply" + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + deleted := at.Add(time.Minute) + livePost := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyroot"} + liveSubject := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafysubject"} + group := func(subject, rootURI string) notifications.ListedNotification { + subjectReference := liveSubject + if subject == rootURI { + subjectReference = livePost + } + return notifications.ListedNotification{Reason: notifications.ReasonUpvote, SubjectURI: subject, RootPostURI: rootURI, + Subject: subjectReference, RootPost: livePost, UpvoteCount: 2, RecentUpvoterDIDs: []string{"did:plc:voter"}, SortAt: at} + } + rows := []notifications.ListedNotification{ + group(missingPost, missingPost), + group(missingComment, root), + group(deletedLive, root), + group(orphan, missingRoot), + group(present, present), + group(placeholder, root), + group(presentComment, missingRoot), + {Reason: notifications.ReasonPostReply, RecordURI: reply, ActorDID: "did:plc:actor", SubjectURI: root, RootPostURI: root, + Record: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyreply"}, Subject: livePost, RootPost: livePost, RecordCreatedAt: at, SortAt: at}, + } + rows[5].Subject = notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeletedcomment"} + logs := captureDefaultLogs(t) + service := notifications.NewListService(cannedListReader{page: notifications.ListPage{Notifications: rows}}, cannedProfiles{}, anonymousPosts{ + root: {URI: root, CID: "bafyroot", Record: map[string]interface{}{"title": "Root"}}, + present: {URI: present, CID: "bafypresent", Record: map[string]interface{}{"title": "Present"}}, + }, cannedComments{ + deletedLive: {URI: deletedLive, CID: "bafyleak", Content: "Must not render", DeletedAt: &deleted}, + presentComment: {URI: presentComment, CID: "bafypresentcomment", Content: "Live comment under a missing root"}, + reply: {URI: reply, CID: "bafyreply", Content: "Reply"}, + }) + got, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, got.Notifications, 3, "five unhydratable upvote groups must be omitted; the placeholder and reply survive") + require.Equal(t, []notifications.Reason{notifications.ReasonUpvote, notifications.ReasonUpvote, notifications.ReasonPostReply}, + []notifications.Reason{got.Notifications[0].Reason, got.Notifications[1].Reason, got.Notifications[2].Reason}) + require.Equal(t, []string{present, placeholder, root}, []string{got.Notifications[0].Subject.URI, got.Notifications[1].Subject.URI, got.Notifications[2].Subject.URI}) + require.Len(t, logs(), 1) + require.Equal(t, slog.LevelWarn, logs()[0].Level) + attrs := map[string]slog.Value{} + logs()[0].Attrs(func(attr slog.Attr) bool { flattenLogAttrs("", []slog.Attr{attr}, attrs); return true }) + require.Equal(t, int64(5), attrs["omitted"].Int64()) + require.Equal(t, int64(5), attrs["by_reason.other"].Int64()) + require.Equal(t, int64(3), attrs["by_cause.missing_root"].Int64(), "a live root absent from the post lookup omits the group even when its subject is present") + require.Equal(t, int64(1), attrs["by_cause.missing_subject"].Int64()) + require.Equal(t, int64(1), attrs["by_cause.deleted_subject"].Int64()) + require.Zero(t, attrs["by_cause.missing_record"].Int64()) + require.Zero(t, attrs["by_cause.deleted_record"].Int64()) + require.Zero(t, attrs["by_reason.postReply"].Int64()) + require.Zero(t, attrs["by_reason.commentReply"].Int64()) + require.Zero(t, attrs["by_cause.unrecognized_reason"].Int64()) +} diff --git a/internal/core/notifications/notification.go b/internal/core/notifications/notification.go new file mode 100644 index 0000000..a77f7ec --- /dev/null +++ b/internal/core/notifications/notification.go @@ -0,0 +1,150 @@ +// Package notifications computes and stores in-app notifications: replies to a +// user's post or comment, mentions, and upvotes on their content. +package notifications + +import "time" + +// Reason is why a notification exists. The set is open on the wire. +type Reason string + +const ( + ReasonPostReply Reason = "postReply" + ReasonCommentReply Reason = "commentReply" + ReasonMention Reason = "mention" + ReasonUpvote Reason = "upvote" +) + +// Intent is one record-keyed notification a fan-out wants written: the record +// (a reply or a mention) that notifies RecipientDID. Writes are idempotent on +// (RecipientDID, Reason, RecordURI). +type Intent struct { + Reason Reason + RecipientDID string + ActorDID string + RecordURI string + RecordCID string + SubjectURI string + RootPostURI string + RecordCreatedAt time.Time +} + +// CommentRecord is the part of an indexed comment that a fan-out reads. +type CommentRecord struct { + URI string + CID string + AuthorDID string + ParentURI string + RootURI string + CreatedAt time.Time + // FacetsJSON is the facets JSON of the record version being indexed; empty + // means none. + FacetsJSON string + // EditEventTime is the edit event's Jetstream time. Zero means no event + // time; freshness falls back to index time without a lookup. + EditEventTime time.Time +} + +// MaxMentionsPerRecord limits surviving mention notifications for one record +// in total across its create, every edit, active re-create, and resurrection. +// The remaining budget is this limit minus the surviving mention rows, read +// inside the writing transaction while the record is serialized. A slot freed +// by retention or account erasure before a resurrection is available again; +// author deletion keeps rows, so their slots remain used on resurrection. +// richtext.MaxFacets remains the parse and lookup bound for facets. +const MaxMentionsPerRecord = 10 + +// Retention thresholds. The windows are exact durations, not calendar days: +// read rows go after 30 days, and when a recipient has more than 500 unread +// rows, unread rows more than 180 days older than their newest row go too. +// Each sweep statement deletes at most RetentionBatchSize rows. +const ( + RetentionReadWindow = 720 * time.Hour + RetentionUnreadCap = 500 + RetentionUnreadWindow = 4320 * time.Hour + RetentionBatchSize = 10_000 + + RetentionHiddenReferenceWindow = 168 * time.Hour +) + +// PostRecord is the part of an indexed postv2 post that a fan-out reads. +type PostRecord struct { + URI string + CID string + AuthorDID string + CreatedAt time.Time + // FacetsJSON is the facets JSON of the post version being indexed; empty + // means none. + FacetsJSON string + // EditEventTime is the edit event's Jetstream time. Zero means no event + // time; freshness falls back to index time without a lookup. + EditEventTime time.Time +} + +// UpvoteGroupAction is what one vote change does to its subject's upvote group. +type UpvoteGroupAction int + +const ( + // UpvoteGroupNoChange leaves the group as it is. It is the zero value. + UpvoteGroupNoChange UpvoteGroupAction = iota + // UpvoteGroupBump creates the group or raises its sort_at to the write time. + UpvoteGroupBump + // UpvoteGroupDeleteIfEmpty deletes the group when no qualifying native + // upvote remains and, with bridged totals enabled, the bridged total is 0. + UpvoteGroupDeleteIfEmpty +) + +// UpvoteGroupIntent is the change a vote fan-out wants applied to the upvote +// group keyed by (RecipientDID, SubjectURI). +type UpvoteGroupIntent struct { + Action UpvoteGroupAction + RecipientDID string + SubjectURI string + // RootPostURI is the post the subject belongs to. Only UpvoteGroupBump uses + // it; it is empty for UpvoteGroupDeleteIfEmpty. + RootPostURI string +} + +// VoteRecord is the part of an indexed vote that the upvote-group fan-out reads. +// FanoutVoteRemoval reads only SubjectURI. +type VoteRecord struct { + // URI is the vote's own AT-URI, excluded from FanoutVoteCreate's + // earlier-upvote check. + URI string + VoterDID string + SubjectURI string + // SubjectRootURI is the stored root_uri of a comment subject, read under the + // subject row lock; empty for a post subject. + SubjectRootURI string + // Direction is "up" or "down". + Direction string + CreatedAt time.Time + // VoterErased is the voter's erasure state from the caller's in-transaction erasure gate. + VoterErased bool +} + +// ReferenceState describes whether content can receive new notifications. Only +// an indexed, live reference permits fan-out. +type ReferenceState int + +const ( + // ReferenceLive permits new notifications for an indexed reference. + ReferenceLive ReferenceState = iota + // ReferenceDeleted marks content withdrawn by its author. + ReferenceDeleted + // ReferenceRemovedByModerator marks content removed by its own community: + // a community withdrawal, or active moderation removals that are all + // community-scope. + ReferenceRemovedByModerator + // ReferenceRemovedByServerAdmin marks content with at least one active + // instance-scope moderation removal. It takes precedence over + // ReferenceRemovedByModerator; an author's delete still reads as + // ReferenceDeleted. + ReferenceRemovedByServerAdmin + // ReferenceUnindexed marks a post or comment reference with no indexed row + // and no active removal. It cannot be checked for withdrawal, so it opens no + // fan-out; a comment that arrives before its root post loses its + // notifications. A URI in any other collection can never be indexed and is + // never Unindexed. Write-side only: the read side reads such a reference as + // hidden. + ReferenceUnindexed +) diff --git a/internal/core/notifications/service.go b/internal/core/notifications/service.go new file mode 100644 index 0000000..a2e11e2 --- /dev/null +++ b/internal/core/notifications/service.go @@ -0,0 +1,59 @@ +package notifications + +import ( + "context" + "fmt" + "time" +) + +// Service serves the notification read endpoints. +type Service interface { + CountUnread(ctx context.Context, recipientDID string) (int, error) + UpdateSeen(ctx context.Context, did string, seenAt time.Time) error +} + +type service struct{ repo ReadRepository } + +// NewService builds the notification service. +func NewService(repo ReadRepository) Service { return service{repo: repo} } + +func (s service) CountUnread(ctx context.Context, recipientDID string) (int, error) { + count, err := s.repo.CountUnread(ctx, recipientDID) + if err != nil { + return 0, fmt.Errorf("count unread notifications: %w", err) + } + return count, nil +} + +func (s service) UpdateSeen(ctx context.Context, did string, seenAt time.Time) error { + return s.repo.UpdateSeen(ctx, did, seenAt) +} + +// PreferencesService serves getPreferences and putPreferences. +type PreferencesService interface { + GetPreferences(ctx context.Context, did string) (Preferences, error) + PutPreferences(ctx context.Context, did string, update PreferencesUpdate) (Preferences, error) +} + +type preferencesService struct{ repo PreferencesRepository } + +// NewPreferencesService builds the notification preferences service. +func NewPreferencesService(repo PreferencesRepository) PreferencesService { + return preferencesService{repo: repo} +} + +func (s preferencesService) GetPreferences(ctx context.Context, did string) (Preferences, error) { + preferences, err := s.repo.GetPreferences(ctx, did) + if err != nil { + return Preferences{}, fmt.Errorf("get notification preferences: %w", err) + } + return preferences, nil +} + +func (s preferencesService) PutPreferences(ctx context.Context, did string, update PreferencesUpdate) (Preferences, error) { + preferences, err := s.repo.PutPreferences(ctx, did, update) + if err != nil { + return Preferences{}, fmt.Errorf("put notification preferences: %w", err) + } + return preferences, nil +} diff --git a/internal/core/notifications/view.go b/internal/core/notifications/view.go new file mode 100644 index 0000000..3f51893 --- /dev/null +++ b/internal/core/notifications/view.go @@ -0,0 +1,66 @@ +package notifications + +import "Coves/internal/core/posts" + +// ListNotificationsOutput is the social.coves.notification.listNotifications response. +type ListNotificationsOutput struct { + Notifications []NotificationView `json:"notifications"` + Cursor string `json:"cursor,omitempty"` + SeenAt string `json:"seenAt,omitempty"` +} + +// NotificationView is social.coves.notification.defs#notificationView. +type NotificationView struct { + Reason Reason `json:"reason"` + SortAt string `json:"sortAt"` + IsRead bool `json:"isRead"` + RootPost *RootPostView `json:"rootPost,omitempty"` + Subject *SubjectView `json:"subject,omitempty"` + Record *RecordView `json:"record,omitempty"` + Author *ProfileView `json:"author,omitempty"` + UpvoteCount int `json:"upvoteCount,omitempty"` + RecentUpvoters []ProfileView `json:"recentUpvoters,omitempty"` +} + +// RootPostView is the thread's root post. +type RootPostView struct { + URI string `json:"uri"` + CID string `json:"cid"` + Title string `json:"title,omitempty"` + Community *posts.CommunityRef `json:"community,omitempty"` + Status string `json:"status,omitempty"` + Labels *posts.SelfLabels `json:"labels,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + ThumbnailAlt string `json:"thumbnailAlt,omitempty"` +} + +// SubjectView is the recipient's own post or comment. +type SubjectView struct { + URI string `json:"uri"` + CID string `json:"cid"` + Preview string `json:"preview,omitempty"` + Status string `json:"status,omitempty"` + Labels *posts.SelfLabels `json:"labels,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + ThumbnailAlt string `json:"thumbnailAlt,omitempty"` +} + +// RecordView is the record that produced the notification. +type RecordView struct { + URI string `json:"uri"` + CID string `json:"cid"` + Excerpt string `json:"excerpt,omitempty"` + CreatedAt string `json:"createdAt"` + Status string `json:"status,omitempty"` + Labels *posts.SelfLabels `json:"labels,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + ThumbnailAlt string `json:"thumbnailAlt,omitempty"` +} + +// ProfileView is social.coves.actor.defs#profileView. +type ProfileView struct { + DID string `json:"did"` + Handle string `json:"handle,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Avatar *string `json:"avatar,omitempty"` +} diff --git a/internal/core/posts/notification_thumbnail.go b/internal/core/posts/notification_thumbnail.go new file mode 100644 index 0000000..e7902c6 --- /dev/null +++ b/internal/core/posts/notification_thumbnail.go @@ -0,0 +1,126 @@ +package posts + +import ( + neturl "net/url" + "strings" + + "Coves/internal/core/blobs" + "Coves/internal/core/embeds" + "Coves/internal/core/imageproxy" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/ipfs/go-cid" + "github.com/rivo/uniseg" +) + +// PreviewThumbnail projects a stored post embed once and returns its proxied +// first-image or link-card thumbnail and, for images, bounded alt text. +// It may mutate view.Embed through the shared feed projection. +func PreviewThumbnail(view *PostView) (url string, alt string) { + config := blobs.GetImageURLConfig() + if view == nil || !config.ProxyEnabled { + return "", "" + } + base := config.ProxyBaseURL + if config.CDNURL != "" { + base = config.CDNURL + } + // HydrateImageProxyURL trims the trailing slash, turning a host-less "http://" base into "http://img/..." that passes post-projection checks. + proxy, err := neturl.Parse(base) + if err != nil || (proxy.Scheme != "http" && proxy.Scheme != "https") || proxy.Host == "" { + return "", "" + } + embed, ok := view.Embed.(map[string]interface{}) + if !ok { + return "", "" + } + + var media map[string]interface{} + var blob interface{} + var viewType, urlField string + switch embed["$type"] { + case embeds.TypeExternal: + media, ok = embed["external"].(map[string]interface{}) + if !ok { + return "", "" + } + blob, viewType, urlField = media["thumb"], embeds.TypeExternal+"#view", "thumb" + case embeds.TypeImages: + images, isList := embed["images"].([]interface{}) + if !isList || len(images) == 0 { + return "", "" + } + media, ok = images[0].(map[string]interface{}) + if !ok { + return "", "" + } + blob, viewType, urlField = media["image"], embeds.TypeImages+"#view", "thumb" + alt, _ = media["alt"].(string) + default: + return "", "" + } + if _, isBlob := blob.(map[string]interface{}); !isBlob { + return "", "" + } + blobCID := embeds.BlobCID(blob) + if len(blobCID) == 0 || len(blobCID) > 256 { + return "", "" + } + if _, err := cid.Decode(blobCID); err != nil { + return "", "" + } + // cid.Decode accepts every multibase; the image proxy serves only CIDs its own check accepts. + if imageproxy.ValidateCID(blobCID) != nil { + return "", "" + } + + TransformBlobRefsToURLs(view) + projectedEmbed, ok := view.Embed.(map[string]interface{}) + if !ok || projectedEmbed["$type"] != viewType { + return "", "" + } + if viewType == embeds.TypeExternal+"#view" { + media, ok = projectedEmbed["external"].(map[string]interface{}) + } else { + images, isList := projectedEmbed["images"].([]interface{}) + if !isList || len(images) == 0 { + return "", "" + } + media, ok = images[0].(map[string]interface{}) + } + if !ok { + return "", "" + } + projected, ok := media[urlField].(string) + if !ok { + return "", "" + } + if _, err := syntax.ParseURI(projected); err != nil { + return "", "" + } + parsed, err := neturl.Parse(projected) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", "" + } + if viewType == embeds.TypeExternal+"#view" { + return projected, "" + } + return projected, boundedThumbnailAlt(alt) +} + +func boundedThumbnailAlt(alt string) string { + clusters := uniseg.NewGraphemes(alt) + end := 0 + for count := 0; count < 1000 && clusters.Next(); count++ { + _, next := clusters.Positions() + if next > 10000 { + break + } + end = next + } + bounded := alt[:end] + if strings.TrimSpace(bounded) == "" { + return "" + } + return bounded +} diff --git a/internal/core/richtext/facets.go b/internal/core/richtext/facets.go index bc5cf83..e38b8ca 100644 --- a/internal/core/richtext/facets.go +++ b/internal/core/richtext/facets.go @@ -30,8 +30,9 @@ const MaxFacets = 200 const MaxFeaturesPerFacet = 20 // Known feature $types from the social.coves.richtext.facet lexicon. All but -// featureTypeLink carry attribute constraints enforced by checkKnownFeature; -// featureTypeLink is used only by NormalizeLinkURIs, which owns its uri rules. +// featureTypeLink and featureTypeMention carry attribute constraints enforced by +// checkKnownFeature; featureTypeLink is used only by NormalizeLinkURIs, which +// owns its uri rules, and featureTypeMention only by MentionedDIDs. // Only KNOWN types are checked — unknown $types pass untouched, keeping the // union open for forward compatibility. const ( @@ -40,6 +41,7 @@ const ( featureTypeCodeBlock = "social.coves.richtext.facet#codeBlock" featureTypeSpoiler = "social.coves.richtext.facet#spoiler" featureTypeLink = "social.coves.richtext.facet#link" + featureTypeMention = "social.coves.richtext.facet#mention" ) const ( diff --git a/internal/core/richtext/mentions.go b/internal/core/richtext/mentions.go new file mode 100644 index 0000000..16c0023 --- /dev/null +++ b/internal/core/richtext/mentions.go @@ -0,0 +1,62 @@ +package richtext + +import ( + "encoding/json" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// MentionedDIDs returns the first MaxFacets valid, distinct mention DIDs in +// facet and feature order. dropped counts the further valid, distinct mention +// DIDs that were not returned; a repeat of a DID already seen is never counted. +// Invalid facet JSON or malformed entries are ignored. +// +// The cap is the bound on mentions per record. Stored facets are capped at +// MaxFacets, but each facet may carry MaxFeaturesPerFacet features over +// overlapping ranges, so without it one record could name +// MaxFacets*MaxFeaturesPerFacet distinct DIDs. +func MentionedDIDs(facetsJSON string) (dids []string, dropped int) { + var facets []any + if err := json.Unmarshal([]byte(facetsJSON), &facets); err != nil { + return nil, 0 + } + + seen := make(map[string]struct{}) + for _, entry := range facets { + facet, ok := entry.(map[string]any) + if !ok { + continue + } + features, ok := facet["features"].([]any) + if !ok { + continue + } + for _, entry := range features { + feature, ok := entry.(map[string]any) + if !ok { + continue + } + featureType, ok := feature["$type"].(string) + if !ok || featureType != featureTypeMention { + continue + } + did, ok := feature["did"].(string) + if !ok { + continue + } + if _, err := syntax.ParseDID(did); err != nil { + continue + } + if _, exists := seen[did]; exists { + continue + } + seen[did] = struct{}{} + if len(dids) == MaxFacets { + dropped++ + continue + } + dids = append(dids, did) + } + } + return dids, dropped +} diff --git a/internal/core/richtext/mentions_test.go b/internal/core/richtext/mentions_test.go new file mode 100644 index 0000000..b4ed195 --- /dev/null +++ b/internal/core/richtext/mentions_test.go @@ -0,0 +1,178 @@ +package richtext + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMentionedDIDs(t *testing.T) { + const ( + mentionedB = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" + mentionedD = "did:plc:dddddddddddddddddddddddd" + mentionType = "social.coves.richtext.facet#mention" + ) + mention := func(did interface{}) map[string]interface{} { + return map[string]interface{}{"$type": mentionType, "did": did} + } + facetWithFeatures := func(features ...interface{}) map[string]interface{} { + return map[string]interface{}{ + "index": map[string]interface{}{"byteStart": 0, "byteEnd": 1}, + "features": features, + } + } + storedJSON := func(facets interface{}) string { + t.Helper() + data, err := json.Marshal(facets) + require.NoError(t, err, "encode the stored facet fixture") + return string(data) + } + + tests := []struct { + name string + facetsJSON string + want []string + }{ + { + name: "duplicate B across facets keeps first occurrence before D", + facetsJSON: storedJSON([]interface{}{ + facetWithFeatures(mention(mentionedB)), + facetWithFeatures(mention(mentionedD)), + facetWithFeatures(mention(mentionedB)), + }), + want: []string{mentionedB, mentionedD}, + }, + { + name: "two mentions in one facet retain feature order", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(mention(mentionedB), mention(mentionedD))}), + want: []string{mentionedB, mentionedD}, + }, + { + name: "link tag and unknown feature types do not contribute DIDs", + facetsJSON: storedJSON([]interface{}{facetWithFeatures( + map[string]interface{}{"$type": "social.coves.richtext.facet#link", "did": mentionedB}, + map[string]interface{}{"$type": "social.coves.richtext.facet#tag", "did": mentionedB}, + map[string]interface{}{"$type": "social.coves.richtext.facet#future", "did": mentionedB}, + mention(mentionedD), + )}), + want: []string{mentionedD}, + }, + { + name: "non-object facet is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{"not a facet", facetWithFeatures(mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "missing features is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{map[string]interface{}{"index": map[string]interface{}{"byteStart": 0, "byteEnd": 1}}, facetWithFeatures(mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "non-array features is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{map[string]interface{}{"features": mention(mentionedB)}, facetWithFeatures(mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "non-object feature is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures("not a feature", mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "mention missing did is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(map[string]interface{}{"$type": mentionType}, mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "numeric did is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(mention(42), mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "null did is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(mention(nil), mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "object did is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(mention(map[string]interface{}{"did": mentionedB}), mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "non-DID string is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(mention("not-a-did"), mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "empty DID is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(mention("did:"), mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "DID with no identifier is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(mention("did:plc:"), mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "missing feature type is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(map[string]interface{}{"did": mentionedB}, mention(mentionedD))}), + want: []string{mentionedD}, + }, + { + name: "non-string feature type is skipped beside a valid mention", + facetsJSON: storedJSON([]interface{}{facetWithFeatures(map[string]interface{}{"$type": 42, "did": mentionedB}, mention(mentionedD))}), + want: []string{mentionedD}, + }, + {name: "empty string has no mentions", facetsJSON: ""}, + {name: "null has no mentions", facetsJSON: "null"}, + {name: "empty array has no mentions", facetsJSON: "[]"}, + {name: "invalid JSON has no mentions", facetsJSON: `[{`}, + {name: "object instead of array has no mentions", facetsJSON: storedJSON(facetWithFeatures(mention(mentionedB)))}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, dropped := MentionedDIDs(test.facetsJSON) + require.Zero(t, dropped, "no case under the cap drops a mention") + if len(test.want) == 0 { + require.Empty(t, got) + } else { + require.Equal(t, test.want, got) + } + }) + } +} + +// Stored facets are capped at MaxFacets, but each may carry MaxFeaturesPerFacet +// mentions over overlapping ranges. MentionedDIDs keeps the first MaxFacets +// distinct DIDs and counts only further distinct DIDs as dropped. +func TestMentionedDIDs_CapsDistinctDIDsAtMaxFacets(t *testing.T) { + const distinctCount = 15 * MaxFeaturesPerFacet + require.Greater(t, distinctCount, MaxFacets, "fixture: more distinct mentions than the cap") + distinct := make([]string, 0, distinctCount) + for index := 0; index < distinctCount; index++ { + distinct = append(distinct, fmt.Sprintf("did:plc:cappedmention%03d", index)) + } + // Every DID appears once in facet order; the last facets then repeat DIDs + // from both sides of the cap, which must count neither as kept nor dropped. + ordered := append(append([]string{}, distinct...), distinct[0], distinct[MaxFacets-1], distinct[MaxFacets], distinct[distinctCount-1]) + var facets []interface{} + for start := 0; start < len(ordered); start += MaxFeaturesPerFacet { + end := min(start+MaxFeaturesPerFacet, len(ordered)) + features := make([]interface{}, 0, end-start) + for _, did := range ordered[start:end] { + features = append(features, map[string]interface{}{"$type": "social.coves.richtext.facet#mention", "did": did}) + } + facets = append(facets, map[string]interface{}{ + "index": map[string]interface{}{"byteStart": 0, "byteEnd": 1}, + "features": features, + }) + } + data, err := json.Marshal(facets) + require.NoError(t, err, "encode the stored facet fixture") + + got, dropped := MentionedDIDs(string(data)) + require.Equal(t, distinct[:MaxFacets], got, "the first MaxFacets distinct DIDs, in facet and feature order") + require.Equal(t, distinctCount-MaxFacets, dropped, "only distinct DIDs past the cap count as dropped") +} diff --git a/internal/db/migrations/053_notifications.sql b/internal/db/migrations/053_notifications.sql new file mode 100644 index 0000000..9afbe7a --- /dev/null +++ b/internal/db/migrations/053_notifications.sql @@ -0,0 +1,58 @@ +-- +goose Up +CREATE TABLE notification_activation ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + activated_at TIMESTAMPTZ NOT NULL +); +INSERT INTO notification_activation (activated_at) VALUES (NOW()); + +CREATE TABLE notifications ( + id BIGSERIAL PRIMARY KEY, + recipient_did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + reason TEXT NOT NULL CHECK (reason IN ('postReply','commentReply','mention','upvote')), + record_uri TEXT, -- the reply or mentioning record; NULL for upvote groups + record_cid TEXT, + actor_did TEXT, -- NULL for upvote groups + subject_uri TEXT, -- recipient's own post/comment; NULL for mention + root_post_uri TEXT NOT NULL, -- navigation target for every reason + record_created_at TIMESTAMPTZ, -- display only ("2h ago"); NULL for upvote groups + sort_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- index time; bumped by new upvotes + CHECK ( + (reason = 'upvote' AND record_uri IS NULL AND record_cid IS NULL AND actor_did IS NULL + AND subject_uri IS NOT NULL) + OR (reason IN ('postReply','commentReply') AND record_uri IS NOT NULL AND record_cid IS NOT NULL + AND actor_did IS NOT NULL AND subject_uri IS NOT NULL) + OR (reason = 'mention' AND record_uri IS NOT NULL AND record_cid IS NOT NULL + AND actor_did IS NOT NULL AND subject_uri IS NULL) + ) +); + +CREATE UNIQUE INDEX uq_notifications_record + ON notifications (recipient_did, reason, record_uri) WHERE reason <> 'upvote'; +CREATE UNIQUE INDEX uq_notifications_upvote_group + ON notifications (recipient_did, subject_uri) WHERE reason = 'upvote'; +CREATE INDEX idx_notifications_recipient_sort + ON notifications (recipient_did, sort_at DESC, id DESC); +CREATE INDEX idx_notifications_record ON notifications (record_uri) WHERE record_uri IS NOT NULL; +CREATE INDEX idx_notifications_actor ON notifications (actor_did) WHERE actor_did IS NOT NULL; + +CREATE TABLE notification_state ( + did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, + seen_at TIMESTAMPTZ, -- NULL: never marked seen + disabled_reasons TEXT[] NOT NULL DEFAULT '{}' +); + +ALTER TABLE posts ADD COLUMN bridged_upvote_peak INT NOT NULL DEFAULT 0 + CONSTRAINT posts_bridged_upvote_peak_nonnegative CHECK (bridged_upvote_peak >= 0); +ALTER TABLE comments ADD COLUMN bridged_upvote_peak INT NOT NULL DEFAULT 0 + CONSTRAINT comments_bridged_upvote_peak_nonnegative CHECK (bridged_upvote_peak >= 0); +UPDATE posts SET bridged_upvote_peak = bridged_upvote_count WHERE bridged_upvote_count > 0; +UPDATE comments SET bridged_upvote_peak = bridged_upvote_count WHERE bridged_upvote_count > 0; +COMMENT ON COLUMN posts.bridged_upvote_peak IS 'Highest bridged upvote total the poller has applied to this item, including the pre-update stored count it replaced, or the stored total at launch; upvote notifications bump only above GREATEST(bridged_upvote_peak, bridged_upvote_count).'; +COMMENT ON COLUMN comments.bridged_upvote_peak IS 'Highest bridged upvote total the poller has applied to this item, including the pre-update stored count it replaced, or the stored total at launch; upvote notifications bump only above GREATEST(bridged_upvote_peak, bridged_upvote_count).'; + +-- +goose Down +ALTER TABLE comments DROP COLUMN IF EXISTS bridged_upvote_peak; +ALTER TABLE posts DROP COLUMN IF EXISTS bridged_upvote_peak; +DROP TABLE IF EXISTS notification_state; +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS notification_activation; diff --git a/internal/db/migrations/054_votes_upvote_history_index.sql b/internal/db/migrations/054_votes_upvote_history_index.sql new file mode 100644 index 0000000..f854daa --- /dev/null +++ b/internal/db/migrations/054_votes_upvote_history_index.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- +goose NO TRANSACTION +-- EarlierUpvoteExists must see soft-deleted upvotes; partial indexes on +-- deleted_at IS NULL cannot serve it, and idx_votes_voter otherwise scans every +-- vote the voter ever cast. CONCURRENTLY avoids blocking writes to the large, +-- heavily written votes table. No IF NOT EXISTS: an interrupted concurrent +-- build can leave an INVALID index that must be dropped by an operator with +-- DROP INDEX CONCURRENTLY before retrying, not silently retained. +CREATE INDEX CONCURRENTLY idx_votes_voter_subject_upvotes ON votes (voter_did, subject_uri) WHERE direction = 'up'; + +-- +goose Down +-- +goose NO TRANSACTION +DROP INDEX CONCURRENTLY IF EXISTS idx_votes_voter_subject_upvotes; diff --git a/internal/db/migrations/055_notification_public_post_withdrawals.sql b/internal/db/migrations/055_notification_public_post_withdrawals.sql new file mode 100644 index 0000000..275ae16 --- /dev/null +++ b/internal/db/migrations/055_notification_public_post_withdrawals.sql @@ -0,0 +1,17 @@ +-- +goose Up +CREATE TABLE notification_public_post_withdrawals ( + post_uri TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('authorDelete', 'communityWithdrawal')), + community_rev TEXT, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (post_uri, kind), + CHECK ((kind = 'authorDelete') = (community_rev IS NULL)) +); + +-- Account erasure deletes a user's markers by the post URI's authority; the +-- expression must match user_repo.Delete exactly for the index to serve it. +CREATE INDEX idx_notification_public_post_withdrawals_author + ON notification_public_post_withdrawals ((split_part(post_uri, '/', 3))); + +-- +goose Down +DROP TABLE notification_public_post_withdrawals; diff --git a/internal/db/postgres/admission_repo.go b/internal/db/postgres/admission_repo.go index 81eb954..46b1dbc 100644 --- a/internal/db/postgres/admission_repo.go +++ b/internal/db/postgres/admission_repo.go @@ -18,8 +18,8 @@ import ( // PostgreSQL storage for per-(community, post) admission decisions // (docs/PRD_AUTHOR_OWNED_POSTS.md §5.2, §5.5, §6.1; migration 034). // -// THE SHAPE EVERY MUTATION TAKES. All seven are single-statement compare-and- -// swaps whose guard is the whole decision. Five — the author-repo observation +// THE SHAPE EVERY MUTATION TAKES. All seven use a guarded writing statement +// whose guard is the whole admission decision. Five — the author-repo observation // and the four community events — are one INSERT ... ON CONFLICT DO UPDATE ... // WHERE , because each may legitimately meet an absent subject and must // create the row that records the event was seen. The other two are guarded @@ -27,10 +27,12 @@ import ( // that stands, and a rejection lands on the pending row the engine read from // its own queue. Either way, Postgres evaluates the guard against the current // row inside the writing statement, so two consumers draining overlapping -// Jetstream feeds cannot interleave a read and a write. There is no -// SELECT-then-decide anywhere in this file, which is what makes a duplicate -// delivery — RecordRejection's included — a genuine no-op rather than a -// re-stamped decision timestamp. +// Jetstream feeds cannot interleave a read and a write. ApplyRemoval and +// ApplyAcceptanceDelete first lock and read the admission row in their transaction, +// then decide only whether to maintain a public-withdrawal marker if the guarded +// write applies. The pre-read never decides the admission CAS: a duplicate +// delivery — RecordRejection's included — remains a genuine no-op rather than +// a re-stamped decision timestamp. // // updated_at is set ONLY inside the guarded SET clause. A refused event must // leave the row byte-identical — the moderation audit trail would otherwise @@ -269,8 +271,9 @@ func (r *postgresAdmissionRepo) ApplyAcceptanceDelete(ctx context.Context, cmd p updated_at = NOW()` + communityWatermarkGuard + ` RETURNING ` + admissionColumns - return r.compareAndSwap(ctx, "ApplyAcceptanceDelete", cmd.CommunityDID, cmd.PostURI, communityEventOutcome, rowRequired, - query, cmd.CommunityDID, cmd.PostURI, cmd.Watermark.Rev, int16(posts.CommunityOpDelete)) + return r.compareAndSwapWithWithdrawal(ctx, "ApplyAcceptanceDelete", cmd.CommunityDID, cmd.PostURI, communityEventOutcome, rowRequired, + query, &communityWithdrawal{rev: cmd.Watermark.Rev}, + cmd.CommunityDID, cmd.PostURI, cmd.Watermark.Rev, int16(posts.CommunityOpDelete)) } // ApplyRemoval applies a community removal record write under the §5.2 @@ -306,8 +309,9 @@ func (r *postgresAdmissionRepo) ApplyRemoval(ctx context.Context, cmd posts.Appl updated_at = NOW()` + communityWatermarkGuard + ` RETURNING ` + admissionColumns - return r.compareAndSwap(ctx, "ApplyRemoval", cmd.CommunityDID, cmd.PostURI, communityEventOutcome, rowRequired, - query, cmd.CommunityDID, cmd.PostURI, cmd.DecisionCode, + return r.compareAndSwapWithWithdrawal(ctx, "ApplyRemoval", cmd.CommunityDID, cmd.PostURI, communityEventOutcome, rowRequired, + query, &communityWithdrawal{rev: cmd.Watermark.Rev, removal: true}, + cmd.CommunityDID, cmd.PostURI, cmd.DecisionCode, cmd.Watermark.Rev, int16(posts.CommunityOpPut)) } @@ -782,6 +786,28 @@ func (r *postgresAdmissionRepo) compareAndSwap( mayLackRow bool, query string, args ...interface{}, +) (posts.AdmissionResult, error) { + return r.compareAndSwapWithWithdrawal(ctx, operation, communityDID, postURI, classify, mayLackRow, query, nil, args...) +} + +type communityWithdrawal struct { + rev string + removal bool +} + +// compareAndSwapWithWithdrawal locks the old admission before evaluating whether +// the post was publicly admitted. An active admin removal does not count against +// admission: the community still withdraws a post it had made public, so the +// notification keeps its removed placeholder. The guarded upsert still decides +// whether the admission write applies. +func (r *postgresAdmissionRepo) compareAndSwapWithWithdrawal( + ctx context.Context, + operation, communityDID, postURI string, + classify admissionOutcome, + mayLackRow bool, + query string, + withdrawal *communityWithdrawal, + args ...interface{}, ) (posts.AdmissionResult, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -800,6 +826,33 @@ func (r *postgresAdmissionRepo) compareAndSwap( } }() + var ownCommunityPost, admittedBefore, alreadyRemoved bool + if withdrawal != nil { + var priorStatus posts.AdmissionStatus + err := tx.QueryRowContext(ctx, `SELECT status FROM community_post_admissions + WHERE community_did = $1 AND post_uri = $2 FOR UPDATE`, communityDID, postURI).Scan(&priorStatus) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return posts.AdmissionResult{}, fmt.Errorf("%s for %s in %s: record post community withdrawal: locking admission: %w", operation, postURI, communityDID, err) + } + hadAdmission := err == nil + alreadyRemoved = hadAdmission && priorStatus == posts.AdmissionStatusRemoved + + // This is a later READ COMMITTED statement, after the admission lock has + // settled. With no locked admission, never treat a newly inserted row as + // the pre-write state, even if it appears between these statements. + joinSQL, whereSQL := admittedPostsPredicate(anonymousViewerSQL) + err = tx.QueryRowContext(ctx, `SELECT + EXISTS (SELECT 1 FROM posts p WHERE p.uri = $1 AND p.community_did = $2), + EXISTS (SELECT 1 FROM posts p`+joinSQL+` + WHERE p.uri = $1 AND p.community_did = $2 + AND split_part(p.uri, '/', 4) = $4 AND p.deleted_at IS NULL + AND $3::boolean AND `+whereSQL+`)`, postURI, communityDID, hadAdmission, posts.PostV2Collection). + Scan(&ownCommunityPost, &admittedBefore) + if err != nil { + return posts.AdmissionResult{}, fmt.Errorf("%s for %s in %s: record post community withdrawal: checking admission: %w", operation, postURI, communityDID, err) + } + } + admission, err := scanAdmission(tx.QueryRowContext(ctx, query, args...)) wrote := true if errors.Is(err, sql.ErrNoRows) { @@ -832,6 +885,21 @@ func (r *postgresAdmissionRepo) compareAndSwap( return posts.AdmissionResult{}, fmt.Errorf("%s for %s in %s: %w", operation, postURI, communityDID, err) } + if wrote && withdrawal != nil && ownCommunityPost && (!withdrawal.removal || !alreadyRemoved) { + if admittedBefore { + _, err = tx.ExecContext(ctx, `INSERT INTO notification_public_post_withdrawals + (post_uri, kind, community_rev) VALUES ($1, 'communityWithdrawal', $2) + ON CONFLICT (post_uri, kind) DO UPDATE SET + community_rev = EXCLUDED.community_rev, recorded_at = NOW()`, postURI, withdrawal.rev) + } else if withdrawal.removal { + _, err = tx.ExecContext(ctx, `DELETE FROM notification_public_post_withdrawals + WHERE post_uri = $1 AND kind = 'communityWithdrawal' AND community_rev IS DISTINCT FROM $2`, postURI, withdrawal.rev) + } + if err != nil { + return posts.AdmissionResult{}, fmt.Errorf("%s for %s in %s: record post community withdrawal: %w", operation, postURI, communityDID, err) + } + } + if err := tx.Commit(); err != nil { return posts.AdmissionResult{}, fmt.Errorf("%s for %s in %s: committing: %w", operation, postURI, communityDID, err) } diff --git a/internal/db/postgres/admission_repo_schema_test.go b/internal/db/postgres/admission_repo_schema_test.go index 5d3adfa..d5947ca 100644 --- a/internal/db/postgres/admission_repo_schema_test.go +++ b/internal/db/postgres/admission_repo_schema_test.go @@ -318,10 +318,16 @@ func TestMigration034_DownRestoresTheAuthorForeignKeyUnvalidated(t *testing.T) { require.NoError(t, err, "with fk_author dropped, a federated author's post must index even though no users row exists for them") - // The expected-version parameter is the tripwire. Migrations 035 through 050 - // all sit on top of 034, so all sixteen have to come off first. Rolling back explicitly, + // The expected-version parameter is the tripwire. Migrations 035 through 055 + // all sit on top of 034, so all twenty-one have to come off first. Rolling back explicitly, // one asserted step at a time, is what keeps the assertions below pointed at // 034's Down rather than at whatever happens to be newest. + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), diff --git a/internal/db/postgres/admission_repo_withdrawal_race_test.go b/internal/db/postgres/admission_repo_withdrawal_race_test.go new file mode 100644 index 0000000..be0236e --- /dev/null +++ b/internal/db/postgres/admission_repo_withdrawal_race_test.go @@ -0,0 +1,164 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +// The holder commits a newer admission while the writer is blocked. Observing +// the backend lock wait proves that the writer actually overlaps that commit. +// The writer may wait on either its pre-read lock or the guarded upsert. +func withdrawalBlockedWriter(t *testing.T, db *sql.DB, hold func(*sql.Tx) error, write func(context.Context) (posts.AdmissionResult, error)) posts.AdmissionResult { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + transaction, holderProcessID := notificationRaceTransaction(t, db, ctx) + require.NoError(t, hold(transaction)) + + type writerResult struct { + admission posts.AdmissionResult + err error + } + results := make(chan writerResult, 1) + go func() { + admission, err := write(ctx) + results <- writerResult{admission: admission, err: err} + }() + + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + var processID int + err := db.QueryRowContext(ctx, ` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND $1 = ANY(pg_blocking_pids(pid)) + AND wait_event_type = 'Lock' + AND query ILIKE '%community_post_admissions%' + LIMIT 1`, holderProcessID).Scan(&processID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("admission writer blocked behind the holder transaction")) + select { + case result := <-results: + t.Fatalf("admission writer returned before the holder committed: %v", result.err) + default: + } + require.NoError(t, transaction.Commit()) + select { + case result := <-results: + require.NoError(t, result.err) + return result.admission + case <-ctx.Done(): + t.Fatalf("admission writer did not return after holder committed: %v", ctx.Err()) + return posts.AdmissionResult{} + } +} + +func TestAdmissionRepo_CommunityWithdrawal_ConcurrentExistingRow(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + initialAccepted bool + holderAccepted bool + acceptanceDelete bool + markerRev bool + }{ + {"accepted becomes pending before removal", true, false, false, false}, + {"pending becomes accepted before removal", false, true, false, true}, + {"pending becomes accepted before acceptance delete", false, true, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 4) + subject, cid := newWithdrawalSubject(t, db) + if tc.initialAccepted { + acceptWithdrawalSubject(t, repo, subject, cid, revs[1]) + seedWithdrawalMarker(t, db, subject.PostURI, "communityWithdrawal", revs[0]) + } else { + _, err := repo.UpsertPending(context.Background(), posts.UpsertPendingCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, EvaluatedCID: cid, + }) + require.NoError(t, err) + } + acceptanceURI, acceptanceRkey := acceptanceRecord(t, subject.CommunityDID) + result := withdrawalBlockedWriter(t, db, func(tx *sql.Tx) error { + if tc.holderAccepted { + _, err := tx.ExecContext(context.Background(), `UPDATE community_post_admissions SET + status = 'accepted', acceptance_uri = $3, acceptance_rkey = $4, accepted_cid = $5, + last_community_rev = $6, last_community_op_rank = $7 + WHERE community_did = $1 AND post_uri = $2`, + subject.CommunityDID, subject.PostURI, acceptanceURI, acceptanceRkey, cid, + revs[2], int16(posts.CommunityOpPut)) + return err + } + _, err := tx.ExecContext(context.Background(), `UPDATE community_post_admissions SET + status = 'pending', acceptance_uri = NULL, acceptance_rkey = NULL, accepted_cid = NULL, + last_community_rev = $3, last_community_op_rank = $4 + WHERE community_did = $1 AND post_uri = $2`, + subject.CommunityDID, subject.PostURI, revs[2], int16(posts.CommunityOpDelete)) + return err + }, func(ctx context.Context) (posts.AdmissionResult, error) { + if tc.acceptanceDelete { + return repo.ApplyAcceptanceDelete(ctx, posts.CommunityDeleteCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, + Watermark: posts.CommunityWatermark{Rev: revs[3], OpRank: posts.CommunityOpDelete}, + }) + } + return repo.ApplyRemoval(ctx, posts.ApplyRemovalCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, DecisionCode: "rule_violation", + Watermark: posts.CommunityWatermark{Rev: revs[3], OpRank: posts.CommunityOpPut}, + }) + }) + require.Equal(t, posts.AdmissionApplied, result.Outcome) + status, rank := posts.AdmissionStatusRemoved, posts.CommunityOpPut + if tc.acceptanceDelete { + status, rank = posts.AdmissionStatusPending, posts.CommunityOpDelete + } + requireWithdrawalStatus(t, repo, subject, status, revs[3], rank) + if tc.markerRev { + requireCommunityWithdrawal(t, db, subject.PostURI, revs[3]) + } else { + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } + }) + } +} + +func TestAdmissionRepo_CommunityWithdrawal_ConcurrentAbsentRow(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 2) + subject, cid := newWithdrawalSubject(t, db) + acceptanceURI, acceptanceRkey := acceptanceRecord(t, subject.CommunityDID) + result := withdrawalBlockedWriter(t, db, func(tx *sql.Tx) error { + _, err := tx.ExecContext(context.Background(), `INSERT INTO community_post_admissions + (community_did, post_uri, status, acceptance_uri, acceptance_rkey, accepted_cid, evaluated_cid, + last_community_rev, last_community_op_rank) + VALUES ($1, $2, 'accepted', $3, $4, $5, $5, $6, $7)`, + subject.CommunityDID, subject.PostURI, acceptanceURI, acceptanceRkey, cid, + revs[0], int16(posts.CommunityOpPut)) + return err + }, func(ctx context.Context) (posts.AdmissionResult, error) { + return repo.ApplyRemoval(ctx, posts.ApplyRemovalCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, DecisionCode: "rule_violation", + Watermark: posts.CommunityWatermark{Rev: revs[1], OpRank: posts.CommunityOpPut}, + }) + }) + require.Equal(t, posts.AdmissionApplied, result.Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[1], posts.CommunityOpPut) + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) +} diff --git a/internal/db/postgres/admission_repo_withdrawal_test.go b/internal/db/postgres/admission_repo_withdrawal_test.go new file mode 100644 index 0000000..240d37b --- /dev/null +++ b/internal/db/postgres/admission_repo_withdrawal_test.go @@ -0,0 +1,516 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +var withdrawalHistoricalTime = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + +type withdrawalMarkerRow struct { + kind string + rev sql.NullString + recordedAt time.Time +} + +func withdrawalMarker(t *testing.T, db *sql.DB, postURI, kind string) *withdrawalMarkerRow { + t.Helper() + var row withdrawalMarkerRow + err := db.QueryRowContext(context.Background(), `SELECT kind, community_rev, recorded_at + FROM notification_public_post_withdrawals WHERE post_uri = $1 AND kind = $2`, postURI, kind). + Scan(&row.kind, &row.rev, &row.recordedAt) + if err == sql.ErrNoRows { + return nil + } + require.NoError(t, err) + return &row +} + +func seedWithdrawalMarker(t *testing.T, db *sql.DB, postURI, kind string, rev any) *withdrawalMarkerRow { + t.Helper() + _, err := db.ExecContext(context.Background(), `INSERT INTO notification_public_post_withdrawals + (post_uri, kind, community_rev, recorded_at) VALUES ($1, $2, $3, $4)`, + postURI, kind, rev, withdrawalHistoricalTime) + require.NoError(t, err) + return withdrawalMarker(t, db, postURI, kind) +} + +func requireCommunityWithdrawal(t *testing.T, db *sql.DB, postURI, rev string) *withdrawalMarkerRow { + t.Helper() + marker := withdrawalMarker(t, db, postURI, "communityWithdrawal") + require.NotNil(t, marker, "communityWithdrawal marker for %s", postURI) + require.Equal(t, "communityWithdrawal", marker.kind) + require.Equal(t, sql.NullString{String: rev, Valid: true}, marker.rev) + return marker +} + +func newWithdrawalSubject(t *testing.T, db *sql.DB) (admissionSubject, string) { + t.Helper() + community := visibilityCommunity(t, db, testkit.UniqueID(t)) + author := "did:plc:withdrawal" + testkit.UniqueID(t) + postURI := seedVisibilityPost(t, db, community, author, testkit.TID(), "withdrawal candidate", time.Now()) + return admissionSubject{CommunityDID: community, PostURI: postURI}, postContentCID(t, db, postURI) +} + +func acceptWithdrawalSubject(t *testing.T, repo posts.AdmissionRepository, subject admissionSubject, cid, rev string) { + t.Helper() + uri, rkey := acceptanceRecord(t, subject.CommunityDID) + result, err := repo.ApplyAcceptance(context.Background(), posts.ApplyAcceptanceCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, + AcceptanceURI: uri, AcceptanceRkey: rkey, PinnedCID: cid, + Watermark: posts.CommunityWatermark{Rev: rev, OpRank: posts.CommunityOpPut}, + }) + require.NoError(t, err) + require.Equal(t, posts.AdmissionApplied, result.Outcome) + require.Equal(t, posts.AdmissionStatusAccepted, result.Admission.Status) +} + +func removalWithdrawal(t *testing.T, repo posts.AdmissionRepository, subject admissionSubject, rev string) posts.AdmissionResult { + t.Helper() + result, err := repo.ApplyRemoval(context.Background(), posts.ApplyRemovalCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, + DecisionCode: "rule_violation", + Watermark: posts.CommunityWatermark{Rev: rev, OpRank: posts.CommunityOpPut}, + }) + require.NoError(t, err) + return result +} + +func acceptanceDeleteWithdrawal(t *testing.T, repo posts.AdmissionRepository, subject admissionSubject, rev string) posts.AdmissionResult { + t.Helper() + result, err := repo.ApplyAcceptanceDelete(context.Background(), posts.CommunityDeleteCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, + Watermark: posts.CommunityWatermark{Rev: rev, OpRank: posts.CommunityOpDelete}, + }) + require.NoError(t, err) + return result +} + +func requireWithdrawalStatus(t *testing.T, repo posts.AdmissionRepository, subject admissionSubject, status posts.AdmissionStatus, rev string, rank posts.CommunityOpRank) { + t.Helper() + row, err := repo.Get(context.Background(), subject.CommunityDID, subject.PostURI) + require.NoError(t, err) + require.Equal(t, status, row.Status) + assertWatermark(t, rev, rank, row.LastCommunityEvent) +} + +func TestAdmissionRepo_CommunityWithdrawal_RemovalFirst(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + stale bool + }{ + {"removal then refused acceptance delete", false}, + {"refresh stale marker", true}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 2) + subject, cid := newWithdrawalSubject(t, db) + acceptWithdrawalSubject(t, repo, subject, cid, revs[0]) + if tc.stale { + seedWithdrawalMarker(t, db, subject.PostURI, "communityWithdrawal", revs[0]) + } + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[1]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[1], posts.CommunityOpPut) + marker := requireCommunityWithdrawal(t, db, subject.PostURI, revs[1]) + if tc.stale { + require.True(t, marker.recordedAt.After(withdrawalHistoricalTime), "upsert refreshes recorded_at") + return + } + require.Equal(t, posts.AdmissionSkippedStale, acceptanceDeleteWithdrawal(t, repo, subject, revs[1]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[1], posts.CommunityOpPut) + require.Equal(t, marker, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + }) + } +} + +// Both foreign-community and unindexed admissions deliberately exist: a marker +// must not be cleared just because a decision for the URI happened to exist. +func setupWithdrawalNotVisible(t *testing.T, db *sql.DB, repo posts.AdmissionRepository, kind string, rev string) admissionSubject { + t.Helper() + if kind == "unindexed" { + community := visibilityCommunity(t, db, testkit.UniqueID(t)) + subject := admissionSubject{CommunityDID: community, PostURI: postV2URI("did:plc:unindexed"+testkit.UniqueID(t), testkit.TID())} + _, err := repo.UpsertPending(context.Background(), posts.UpsertPendingCommand{CommunityDID: community, PostURI: subject.PostURI, EvaluatedCID: "bafyunindexed"}) + require.NoError(t, err) + return subject + } + if kind == "legacy" { + subject := newAdmissionSubject(t, db) + acceptWithdrawalSubject(t, repo, subject, postContentCID(t, db, subject.PostURI), rev) + return subject + } + subject, cid := newWithdrawalSubject(t, db) + switch kind { + case "pending": + _, err := repo.UpsertPending(context.Background(), posts.UpsertPendingCommand{CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, EvaluatedCID: cid}) + require.NoError(t, err) + case "pending reacceptance": + acceptWithdrawalSubject(t, repo, subject, cid, rev) + _, err := repo.UpsertPending(context.Background(), posts.UpsertPendingCommand{CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, EvaluatedCID: contentCID(t, "edit")}) + require.NoError(t, err) + case "drifted CID": + acceptWithdrawalSubject(t, repo, subject, cid, rev) + _, err := db.ExecContext(context.Background(), `UPDATE posts SET cid = $2 WHERE uri = $1`, subject.PostURI, contentCID(t, "edit")) + require.NoError(t, err) + case "fork": + other := visibilityCommunity(t, db, testkit.UniqueID(t)) + acceptWithdrawalSubject(t, repo, subject, cid, rev) + subject.CommunityDID = other + acceptWithdrawalSubject(t, repo, subject, cid, rev) + case "no admission": + default: + t.Fatalf("unknown non-visible fixture %q", kind) + } + return subject +} + +func TestAdmissionRepo_CommunityWithdrawal_NotVisibleRemoval(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, fixture string + keep bool + }{ + {"pending clears stale marker", "pending", false}, + {"pending reacceptance clears stale marker", "pending reacceptance", false}, + {"drifted CID clears stale marker", "drifted CID", false}, + {"preemptive removal clears stale marker", "no admission", false}, + {"fork keeps marker", "fork", true}, + {"unindexed keeps marker", "unindexed", true}, + {"legacy does not mark", "legacy", false}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 3) + subject := setupWithdrawalNotVisible(t, db, repo, tc.fixture, revs[1]) + var before *withdrawalMarkerRow + if tc.fixture != "legacy" { + before = seedWithdrawalMarker(t, db, subject.PostURI, "communityWithdrawal", revs[0]) + } + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[2]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[2], posts.CommunityOpPut) + if tc.fixture == "fork" { + var ownStatus posts.AdmissionStatus + err := db.QueryRowContext(context.Background(), `SELECT a.status FROM community_post_admissions a + JOIN posts p ON p.uri = a.post_uri AND p.community_did = a.community_did + WHERE a.post_uri = $1`, subject.PostURI).Scan(&ownStatus) + require.NoError(t, err) + require.Equal(t, posts.AdmissionStatusAccepted, ownStatus) + } + if tc.keep { + require.Equal(t, before, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } else { + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } + }) + } +} + +func TestAdmissionRepo_CommunityWithdrawal_AcceptanceDeleteFirst(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, fixture string + stale bool + }{ + {"acceptance delete then removal", "visible", false}, + {"refresh stale marker", "visible", true}, + {"pending keeps marker", "pending", true}, + {"pending reacceptance keeps marker", "pending reacceptance", true}, + {"drifted CID keeps marker", "drifted CID", true}, + {"fork keeps marker", "fork", true}, + {"unindexed keeps marker", "unindexed", true}, + {"legacy does not mark", "legacy", false}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 3) + var subject admissionSubject + if tc.fixture == "visible" { + var cid string + subject, cid = newWithdrawalSubject(t, db) + acceptWithdrawalSubject(t, repo, subject, cid, revs[1]) + } else { + subject = setupWithdrawalNotVisible(t, db, repo, tc.fixture, revs[1]) + } + var before *withdrawalMarkerRow + if tc.stale { + before = seedWithdrawalMarker(t, db, subject.PostURI, "communityWithdrawal", revs[0]) + } + require.Equal(t, posts.AdmissionApplied, acceptanceDeleteWithdrawal(t, repo, subject, revs[2]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusPending, revs[2], posts.CommunityOpDelete) + if tc.fixture != "visible" { + if tc.stale { + require.Equal(t, before, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } else { + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } + return + } + marker := requireCommunityWithdrawal(t, db, subject.PostURI, revs[2]) + if tc.stale { + require.True(t, marker.recordedAt.After(withdrawalHistoricalTime), "upsert refreshes recorded_at") + return + } + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[2]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[2], posts.CommunityOpPut) + require.Equal(t, marker, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + }) + } +} + +func TestAdmissionRepo_CommunityWithdrawal_StaleMarkersCleared(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + firstRemove bool + }{ + {"acceptance delete then later removal", false}, + {"removal withdrawn then later removal", true}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 4) + subject, cid := newWithdrawalSubject(t, db) + acceptWithdrawalSubject(t, repo, subject, cid, revs[0]) + if tc.firstRemove { + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[1]).Outcome) + } else { + require.Equal(t, posts.AdmissionApplied, acceptanceDeleteWithdrawal(t, repo, subject, revs[1]).Outcome) + } + requireCommunityWithdrawal(t, db, subject.PostURI, revs[1]) + if tc.firstRemove { + result, err := repo.ApplyRemovalDelete(context.Background(), posts.CommunityDeleteCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, + Watermark: posts.CommunityWatermark{Rev: revs[2], OpRank: posts.CommunityOpDelete}, + }) + require.NoError(t, err) + require.Equal(t, posts.AdmissionApplied, result.Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusPending, revs[2], posts.CommunityOpDelete) + } + finalRev := revs[2] + if tc.firstRemove { + finalRev = revs[3] + } + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, finalRev).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, finalRev, posts.CommunityOpPut) + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + }) + } +} + +func TestAdmissionRepo_CommunityWithdrawal_MarkersLeftAlone(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name string }{ + {"already removed"}, + {"stale removal on accepted"}, + {"stale acceptance delete on accepted"}, + {"stale removal on pending"}, + {"author delete survives acceptance delete"}, + {"author delete survives stale marker removal"}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 4) + subject, cid := newWithdrawalSubject(t, db) + switch tc.name { + case "already removed": + acceptWithdrawalSubject(t, repo, subject, cid, revs[0]) + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[1]).Outcome) + before := requireCommunityWithdrawal(t, db, subject.PostURI, revs[1]) + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[2]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[2], posts.CommunityOpPut) + require.Equal(t, before, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + case "stale removal on accepted", "stale acceptance delete on accepted": + acceptWithdrawalSubject(t, repo, subject, cid, revs[2]) + if tc.name == "stale removal on accepted" { + require.Equal(t, posts.AdmissionSkippedStale, removalWithdrawal(t, repo, subject, revs[1]).Outcome) + } else { + require.Equal(t, posts.AdmissionSkippedStale, acceptanceDeleteWithdrawal(t, repo, subject, revs[1]).Outcome) + } + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusAccepted, revs[2], posts.CommunityOpPut) + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + case "stale removal on pending": + acceptWithdrawalSubject(t, repo, subject, cid, revs[0]) + require.Equal(t, posts.AdmissionApplied, acceptanceDeleteWithdrawal(t, repo, subject, revs[2]).Outcome) + before := requireCommunityWithdrawal(t, db, subject.PostURI, revs[2]) + require.Equal(t, posts.AdmissionSkippedStale, removalWithdrawal(t, repo, subject, revs[1]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusPending, revs[2], posts.CommunityOpDelete) + require.Equal(t, before, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + case "author delete survives acceptance delete": + acceptWithdrawalSubject(t, repo, subject, cid, revs[0]) + author := seedWithdrawalMarker(t, db, subject.PostURI, "authorDelete", nil) + require.Equal(t, posts.AdmissionApplied, acceptanceDeleteWithdrawal(t, repo, subject, revs[1]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusPending, revs[1], posts.CommunityOpDelete) + requireCommunityWithdrawal(t, db, subject.PostURI, revs[1]) + require.Equal(t, author, withdrawalMarker(t, db, subject.PostURI, "authorDelete")) + case "author delete survives stale marker removal": + _, err := repo.UpsertPending(context.Background(), posts.UpsertPendingCommand{CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, EvaluatedCID: cid}) + require.NoError(t, err) + author := seedWithdrawalMarker(t, db, subject.PostURI, "authorDelete", nil) + seedWithdrawalMarker(t, db, subject.PostURI, "communityWithdrawal", revs[0]) + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[2]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[2], posts.CommunityOpPut) + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + require.Equal(t, author, withdrawalMarker(t, db, subject.PostURI, "authorDelete")) + } + }) + } +} + +func TestAdmissionRepo_CommunityWithdrawal_SoftDeleted(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + deleteFirst bool + softDeleteLate bool + }{ + {"removal first on deleted post", false, false}, + {"acceptance delete first on deleted post", true, false}, + {"deletion between acceptance delete and removal", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 3) + subject, cid := newWithdrawalSubject(t, db) + acceptWithdrawalSubject(t, repo, subject, cid, revs[1]) + var visibleMarker *withdrawalMarkerRow + if tc.softDeleteLate { + require.Equal(t, posts.AdmissionApplied, acceptanceDeleteWithdrawal(t, repo, subject, revs[2]).Outcome) + visibleMarker = requireCommunityWithdrawal(t, db, subject.PostURI, revs[2]) + } else { + seedWithdrawalMarker(t, db, subject.PostURI, "communityWithdrawal", revs[0]) + } + _, err := db.ExecContext(context.Background(), `UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, subject.PostURI) + require.NoError(t, err) + if tc.deleteFirst && !tc.softDeleteLate { + before := withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal") + require.Equal(t, posts.AdmissionApplied, acceptanceDeleteWithdrawal(t, repo, subject, revs[2]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusPending, revs[2], posts.CommunityOpDelete) + require.Equal(t, before, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[2]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[2], posts.CommunityOpPut) + if tc.softDeleteLate { + require.Equal(t, visibleMarker, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } else { + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } + if !tc.deleteFirst { + require.Equal(t, posts.AdmissionSkippedStale, acceptanceDeleteWithdrawal(t, repo, subject, revs[2]).Outcome) + require.Nil(t, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + } + }) + } +} + +func TestAdmissionRepo_CommunityWithdrawal_RollsBackWithMarkerFailure(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + initialAccepted bool + acceptanceDelete bool + }{ + {"acceptance delete marker insert", true, true}, + {"removal marker insert", true, false}, + {"removal marker delete", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 3) + subject, cid := newWithdrawalSubject(t, db) + if tc.initialAccepted { + acceptWithdrawalSubject(t, repo, subject, cid, revs[0]) + } else { + _, err := repo.UpsertPending(context.Background(), posts.UpsertPendingCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, EvaluatedCID: cid, + }) + require.NoError(t, err) + seedWithdrawalMarker(t, db, subject.PostURI, "communityWithdrawal", revs[1]) + } + before, err := repo.Get(context.Background(), subject.CommunityDID, subject.PostURI) + require.NoError(t, err) + beforeCommunity := withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal") + beforeAuthor := withdrawalMarker(t, db, subject.PostURI, "authorDelete") + + _, err = db.ExecContext(context.Background(), `CREATE FUNCTION fail_community_withdrawal_marker() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'marker write rejected'; END; $$`) + require.NoError(t, err) + _, err = db.ExecContext(context.Background(), `CREATE TRIGGER fail_community_withdrawal_marker + BEFORE INSERT OR UPDATE OR DELETE ON notification_public_post_withdrawals + FOR EACH ROW EXECUTE FUNCTION fail_community_withdrawal_marker()`) + require.NoError(t, err) + + if tc.acceptanceDelete { + _, err = repo.ApplyAcceptanceDelete(context.Background(), posts.CommunityDeleteCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, + Watermark: posts.CommunityWatermark{Rev: revs[2], OpRank: posts.CommunityOpDelete}, + }) + } else { + _, err = repo.ApplyRemoval(context.Background(), posts.ApplyRemovalCommand{ + CommunityDID: subject.CommunityDID, PostURI: subject.PostURI, DecisionCode: "rule_violation", + Watermark: posts.CommunityWatermark{Rev: revs[2], OpRank: posts.CommunityOpPut}, + }) + } + require.ErrorContains(t, err, "record post community withdrawal") + after, getError := repo.Get(context.Background(), subject.CommunityDID, subject.PostURI) + require.NoError(t, getError) + require.Equal(t, before, after, "admission and watermark must roll back with the marker") + require.Equal(t, beforeCommunity, withdrawalMarker(t, db, subject.PostURI, "communityWithdrawal")) + require.Equal(t, beforeAuthor, withdrawalMarker(t, db, subject.PostURI, "authorDelete")) + }) + } +} + +// An active admin removal hides the post from the read path, but the post was +// still admitted to the public by its community; the community's own withdrawal +// must leave the marker so the notification keeps its removed placeholder. +func TestAdmissionRepo_CommunityWithdrawal_AdminRemovedPost(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + communityScope bool + acceptanceDelete bool + }{ + {"instance removal then community removal", false, false}, + {"community-scope removal then community removal", true, false}, + {"instance removal then acceptance delete", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + repo := NewAdmissionRepository(db) + revs := increasingRevs(t, 2) + subject, cid := newWithdrawalSubject(t, db) + acceptWithdrawalSubject(t, repo, subject, cid, revs[0]) + scope := "" + if tc.communityScope { + scope = subject.CommunityDID + } + seedModerationDecision(t, db, subject.PostURI, "removal", scope, true) + if tc.acceptanceDelete { + require.Equal(t, posts.AdmissionApplied, acceptanceDeleteWithdrawal(t, repo, subject, revs[1]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusPending, revs[1], posts.CommunityOpDelete) + } else { + require.Equal(t, posts.AdmissionApplied, removalWithdrawal(t, repo, subject, revs[1]).Outcome) + requireWithdrawalStatus(t, repo, subject, posts.AdmissionStatusRemoved, revs[1], posts.CommunityOpPut) + } + requireCommunityWithdrawal(t, db, subject.PostURI, revs[1]) + }) + } +} diff --git a/internal/db/postgres/bridged_votes_notifications_race_test.go b/internal/db/postgres/bridged_votes_notifications_race_test.go new file mode 100644 index 0000000..3dba655 --- /dev/null +++ b/internal/db/postgres/bridged_votes_notifications_race_test.go @@ -0,0 +1,241 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "Coves/internal/core/bridgedvotes" + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type bridgedRaceResult struct { + applied bool + err error +} + +type bridgedRaceReached struct { + processID int + groupSort time.Time +} + +func bridgedRaceApply(ctx context.Context, store *BridgedVotesRepository, uri string, total int, asOf time.Time) <-chan bridgedRaceResult { + results := make(chan bridgedRaceResult, 1) + go func() { + applied, err := store.ApplyAggregate(ctx, bridgedvotes.Aggregate{URI: uri, Upvotes: total, AsOf: asOf}) + results <- bridgedRaceResult{applied, err} + }() + return results +} + +func bridgedRaceResultWithin(t *testing.T, ctx context.Context, results <-chan bridgedRaceResult) bridgedRaceResult { + t.Helper() + select { + case result := <-results: + require.False(t, notificationRaceDeadlock(result.err), "ApplyAggregate deadlocked: %v", result.err) + return result + case <-ctx.Done(): + t.Fatalf("ApplyAggregate did not finish: %v", ctx.Err()) + return bridgedRaceResult{} + } +} + +func bridgedRaceReachedWithin(t *testing.T, ctx context.Context, reached <-chan bridgedRaceReached) bridgedRaceReached { + t.Helper() + wait, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + select { + case state := <-reached: + return state + case <-wait.Done(): + t.Fatalf("never reached ApplyUpvoteGroupTx: %v", wait.Err()) + return bridgedRaceReached{} + } +} + +func bridgedRaceBlocked(t *testing.T, db *sql.DB, ctx context.Context, blockingPID int, queryFragment string) { + t.Helper() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + var pid int + err := db.QueryRowContext(ctx, `SELECT pid FROM pg_stat_activity + WHERE datname = current_database() AND pid <> pg_backend_pid() + AND $1 = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock' + AND query ILIKE $2 LIMIT 1`, blockingPID, "%"+queryFragment+"%").Scan(&pid) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("%s blocked by the winning subject transaction", queryFragment)) +} + +func bridgedRacePending(t *testing.T, results <-chan bridgedRaceResult) { + t.Helper() + select { + case result := <-results: + t.Fatalf("losing ApplyAggregate finished before the winner committed: %+v", result) + default: + } +} + +// Each row holds its winning subject transaction, proves the competing writer +// waits on that backend, and only then permits the winner to commit. +func TestBridgedVotesNotifications_ConcurrentSubjectChanges(t *testing.T) { + for _, row := range []struct { + name string + kind string + run func(*testing.T, bridgedNotificationFixture, context.Context) + }{ + {"recipient erasure waits for poller", "post", bridgedRaceRecipientErasure}, + {"soft delete wins before poller", "post", bridgedRaceDeleteFirst}, + {"post previous total read under lock", "post", bridgedRacePreviousTotal}, + {"comment previous total read under lock", "comment", bridgedRacePreviousTotal}, + } { + t.Run(row.name, func(t *testing.T) { + f := newBridgedNotificationFixture(t, row.kind, "native") + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + t.Cleanup(cancel) + row.run(t, f, ctx) + }) + } +} + +func bridgedRaceRecipientErasure(t *testing.T, f bridgedNotificationFixture, ctx context.Context) { + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unblock) + reached := make(chan bridgedRaceReached, 1) + repo := f.repository() + decorated := bridgedNotificationRepositoryDecorator{Repository: repo} + decorated.apply = func(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + var pid int + if err := tx.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&pid); err != nil { + return err + } + reached <- bridgedRaceReached{processID: pid} + select { + case <-release: + case <-ctx.Done(): + return ctx.Err() + } + return repo.ApplyUpvoteGroupTx(ctx, tx, intent) + } + apply := bridgedRaceApply(ctx, f.store(decorated), f.subject, 2, f.now.Add(time.Minute)) + state := bridgedRaceReachedWithin(t, ctx, reached) + deletes := make(chan error, 1) + go func() { deletes <- NewUserRepository(f.db).Delete(ctx, f.recipient) }() + notificationRaceBlockedDelete(t, f.db, ctx, state.processID, "DELETE FROM posts") + notificationRaceRequirePending(t, deletes) + unblock() + result := bridgedRaceResultWithin(t, ctx, apply) + require.NoError(t, result.err) + require.True(t, result.applied) + select { + case err := <-deletes: + require.False(t, notificationRaceDeadlock(err), "Delete deadlocked: %v", err) + require.NoError(t, err) + case <-ctx.Done(): + t.Fatalf("Delete did not finish: %v", ctx.Err()) + } + require.Zero(t, f.groupCount(t), "erasure must remove the poller's upvote group") +} + +func bridgedRaceDeleteFirst(t *testing.T, f bridgedNotificationFixture, ctx context.Context) { + transaction, pid := notificationRaceTransaction(t, f.db, ctx) + _, err := transaction.ExecContext(ctx, `UPDATE posts SET deleted_at = now() WHERE uri = $1`, f.subject) + require.NoError(t, err) + apply := bridgedRaceApply(ctx, f.store(f.repository()), f.subject, 2, f.now.Add(time.Minute)) + bridgedRaceBlocked(t, f.db, ctx, pid, f.table) + bridgedRacePending(t, apply) + require.NoError(t, transaction.Commit()) + result := bridgedRaceResultWithin(t, ctx, apply) + require.NoError(t, result.err) + require.False(t, result.applied, "deleted subject must not accept an aggregate") + require.Zero(t, f.groupCount(t)) +} + +func bridgedRacePreviousTotal(t *testing.T, f bridgedNotificationFixture, ctx context.Context) { + seedStoredAggregate(t, ctx, f.db, f.table, f.subject, 3, 0, f.now.Add(-time.Hour)) + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unblock) + reached := make(chan bridgedRaceReached, 1) + committedSort := make(chan time.Time, 1) + var first atomic.Bool + var mu sync.Mutex + bumps := 0 + repo := f.repository() + decorated := bridgedNotificationRepositoryDecorator{Repository: repo} + decorated.apply = func(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + if intent.Action == notifications.UpvoteGroupBump { + mu.Lock() + bumps++ + mu.Unlock() + } + isFirst := !first.Swap(true) + if isFirst { + var pid int + if err := tx.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&pid); err != nil { + return err + } + reached <- bridgedRaceReached{processID: pid} + select { + case <-release: + case <-ctx.Done(): + return ctx.Err() + } + } + if err := repo.ApplyUpvoteGroupTx(ctx, tx, intent); err != nil { + return err + } + if isFirst { + var sortAt time.Time + if err := tx.QueryRowContext(ctx, `SELECT sort_at FROM notifications + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, f.recipient, f.subject).Scan(&sortAt); err != nil { + return err + } + committedSort <- sortAt.UTC().Truncate(time.Microsecond) + } + return nil + } + store := f.store(decorated) + firstApply := bridgedRaceApply(ctx, store, f.subject, 5, f.now) + state := bridgedRaceReachedWithin(t, ctx, reached) + secondAsOf := f.now.Add(time.Millisecond) + secondApply := bridgedRaceApply(ctx, store, f.subject, 5, secondAsOf) + bridgedRaceBlocked(t, f.db, ctx, state.processID, f.table) + bridgedRacePending(t, secondApply) + unblock() + firstResult := bridgedRaceResultWithin(t, ctx, firstApply) + require.NoError(t, firstResult.err) + require.True(t, firstResult.applied) + var firstSort time.Time + select { + case firstSort = <-committedSort: + case <-ctx.Done(): + t.Fatalf("first upvote group sort_at was not recorded: %v", ctx.Err()) + } + secondResult := bridgedRaceResultWithin(t, ctx, secondApply) + require.NoError(t, secondResult.err) + require.True(t, secondResult.applied) + var storedTotal int + var storedAsOf time.Time + require.NoError(t, f.db.QueryRowContext(ctx, `SELECT bridged_upvote_count, bridged_stats_as_of FROM `+f.table+` WHERE uri = $1`, f.subject).Scan(&storedTotal, &storedAsOf)) + require.Equal(t, 5, storedTotal) + require.True(t, storedAsOf.Equal(secondAsOf.UTC().Truncate(time.Microsecond))) + require.Equal(t, 1, f.groupCount(t)) + require.True(t, f.groupSort(t).Equal(firstSort), "the equal-total second application must not bump") + mu.Lock() + defer mu.Unlock() + require.Equal(t, 1, bumps, "only the increase from the locked previous total sends Bump") +} diff --git a/internal/db/postgres/bridged_votes_notifications_test.go b/internal/db/postgres/bridged_votes_notifications_test.go new file mode 100644 index 0000000..b6401ee --- /dev/null +++ b/internal/db/postgres/bridged_votes_notifications_test.go @@ -0,0 +1,439 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/bridgedvotes" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type bridgedNotificationHosts struct{} + +func (bridgedNotificationHosts) TrustsPDS(url string) bool { return url == bridgeAPDSURL } + +type bridgedNotificationFixture struct { + db *sql.DB + ctx context.Context + now time.Time + community string + recipient string + subject string + root string + table string +} + +// Recipient modes are restricted to the fan-out gates exercised by B6. +func newBridgedNotificationFixture(t *testing.T, kind, recipientMode string) bridgedNotificationFixture { + t.Helper() + f := bridgedNotificationFixture{db: testkit.DB(t), ctx: context.Background(), table: "posts"} + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT now()`).Scan(&f.now)) + f.now = f.now.UTC().Truncate(time.Microsecond) + f.community = visibilityCommunity(t, f.db, testkit.UniqueID(t)) + _, err := f.db.ExecContext(f.ctx, `UPDATE communities SET pds_url = $2 WHERE did = $1`, f.community, bridgeAPDSURL) + require.NoError(t, err) + id := testkit.UniqueID(t) + f.recipient = "did:plc:" + id + "recipient" + switch recipientMode { + case "absent": + case "bridge": + _, err = f.db.ExecContext(f.ctx, `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, $4)`, + f.recipient, id+"recipient.test", bridgeAPDSURL, f.now) + require.NoError(t, err) + default: + createTestUser(t, f.db, id+"recipient.test", f.recipient) + } + if recipientMode == "erased" { + _, err = f.db.ExecContext(f.ctx, `INSERT INTO deleted_accounts (did) VALUES ($1)`, f.recipient) + require.NoError(t, err) + _, err = f.db.ExecContext(f.ctx, `DELETE FROM users WHERE did = $1`, f.recipient) + require.NoError(t, err) + } + if recipientMode == "aggregator" { + _, err = f.db.ExecContext(f.ctx, `INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, 'Bridge fixture', $2, 'bafybridgednotificationaggregator')`, f.recipient, + "at://"+f.recipient+"/social.coves.aggregator.service/self") + require.NoError(t, err) + } + postAuthor := f.recipient + if kind == "comment" { + f.table = "comments" + postAuthor = "did:plc:" + testkit.UniqueID(t) + "root" + createTestUser(t, f.db, testkit.UniqueID(t)+"root.test", postAuthor) + } + f.root = seedVisibilityPost(t, f.db, f.community, postAuthor, testkit.TID(), "bridged notification root", f.now.Add(-8*24*time.Hour)) + seedVisibilityAdmission(t, f.db, f.community, f.root, posts.AdmissionStatusAccepted, "", "") + f.subject = f.root + if kind == "comment" { + f.subject = seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), f.now.Add(-8*24*time.Hour)) + } + // seedVisibilityPost supplies a denormalized native vote for feed tests; these + // cases start with none and add actual votes explicitly where relevant. + _, err = f.db.ExecContext(f.ctx, `UPDATE posts SET upvote_count = 0, score = 0 WHERE uri = $1`, f.root) + require.NoError(t, err) + return f +} + +func (f bridgedNotificationFixture) repository() notifications.Repository { + return NewNotificationRepository(f.db, WithBridgedUpvoteTotals()) +} + +func (f bridgedNotificationFixture) store(repo notifications.Repository) *BridgedVotesRepository { + return NewBridgedVotesRepository(f.db, WithBridgedVoteNotifications(repo, bridgedNotificationHosts{})) +} + +func (f bridgedNotificationFixture) apply(t *testing.T, repo notifications.Repository, up, down int, asOf time.Time, want bool) { + t.Helper() + applied, err := f.store(repo).ApplyAggregate(f.ctx, bridgedvotes.Aggregate{ + URI: f.subject, Upvotes: up, Downvotes: down, AsOf: asOf, + }) + require.NoError(t, err) + require.Equal(t, want, applied) +} + +func (f bridgedNotificationFixture) groupCount(t *testing.T) int { + t.Helper() + var count int + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND subject_uri = $2 AND reason = 'upvote'`, f.recipient, f.subject).Scan(&count)) + return count +} + +func (f bridgedNotificationFixture) groupSort(t *testing.T) time.Time { + t.Helper() + var at time.Time + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT sort_at FROM notifications + WHERE recipient_did = $1 AND subject_uri = $2 AND reason = 'upvote'`, f.recipient, f.subject).Scan(&at)) + return at.UTC().Truncate(time.Microsecond) +} + +func (f bridgedNotificationFixture) seedGroup(t *testing.T, at time.Time) { + t.Helper() + insertDeleteTestGroup(t, upvoteGroupFixture{ctx: f.ctx, db: f.db, rootPostURI: f.root}, f.recipient, f.subject, at) +} + +func (f bridgedNotificationFixture) nativeUpvote(t *testing.T) { + t.Helper() + voter := "did:plc:" + testkit.UniqueID(t) + "voter" + qualifyingUpvoteFixture{db: f.db}.insertVote(t, voter, f.subject, "up", f.now, false) + _, err := f.db.ExecContext(f.ctx, `UPDATE `+f.table+` SET upvote_count = 1, score = score + 1 WHERE uri = $1`, f.subject) + require.NoError(t, err) +} + +func (f bridgedNotificationFixture) unread(t *testing.T, repo notifications.Repository, want int) { + t.Helper() + read := repo.(notifications.ReadRepository) + count, err := read.CountUnread(f.ctx, f.recipient) + require.NoError(t, err) + require.Equal(t, want, count) +} + +func (f bridgedNotificationFixture) listedUpvotes(t *testing.T, repo notifications.Repository, want int) { + t.Helper() + page, err := repo.(notifications.ReadRepository).List(f.ctx, f.recipient, "", 10) + require.NoError(t, err) + require.Len(t, page.Notifications, 1) + require.Equal(t, notifications.ReasonUpvote, page.Notifications[0].Reason) + require.Equal(t, f.subject, page.Notifications[0].SubjectURI) + require.Equal(t, want, page.Notifications[0].UpvoteCount) +} + +func (f bridgedNotificationFixture) markSeen(t *testing.T, repo notifications.Repository) { + t.Helper() + require.NoError(t, repo.(notifications.ReadRepository).UpdateSeen(f.ctx, f.recipient, f.now.Add(time.Hour))) + f.unread(t, repo, 0) + var seenAt time.Time + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT seen_at FROM notification_state WHERE did = $1`, f.recipient).Scan(&seenAt)) + f.after(t, seenAt.UTC().Truncate(time.Microsecond)) +} + +// Wait only for the database clock to pass the prior sort/seen timestamp. This +// prevents a legitimate same-microsecond NOW() from obscuring a subsequent bump. +func (f bridgedNotificationFixture) after(t *testing.T, timestamp time.Time) { + t.Helper() + testkit.WaitFor(t, 2*time.Second, func() (bool, error) { + var now time.Time + err := f.db.QueryRowContext(f.ctx, `SELECT now()`).Scan(&now) + return now.UTC().Truncate(time.Microsecond).After(timestamp), err + }, testkit.WithDescription("database clock after the previous upvote group sort")) +} + +type bridgedNotificationRepositoryDecorator struct { + notifications.Repository + apply func(context.Context, *sql.Tx, notifications.UpvoteGroupIntent) error + lookups func(notifications.Lookups) notifications.Lookups +} + +func (d bridgedNotificationRepositoryDecorator) ApplyUpvoteGroupTx(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + if d.apply != nil { + return d.apply(ctx, tx, intent) + } + return d.Repository.ApplyUpvoteGroupTx(ctx, tx, intent) +} + +func (d bridgedNotificationRepositoryDecorator) LookupsTx(tx *sql.Tx) notifications.Lookups { + lookups := d.Repository.LookupsTx(tx) + if d.lookups != nil { + return d.lookups(lookups) + } + return lookups +} + +type bridgedFailRecipientFacts struct { + notifications.Lookups + err error +} + +func (d bridgedFailRecipientFacts) RecipientFacts(context.Context, string, []string) (map[string]notifications.RecipientFacts, error) { + return nil, d.err +} + +func TestBridgedVotesNotifications_FirstIncreaseUsesAggregateTransaction(t *testing.T) { + for _, kind := range []string{"post", "comment"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, kind, "native") + repo := f.repository() + var transactionNow, clockBeforeWrite, clockAfterWrite time.Time + var isolation string + var visibleTotal int + called := false + decorated := bridgedNotificationRepositoryDecorator{Repository: repo} + decorated.apply = func(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + called = true + if err := tx.QueryRowContext(ctx, `SHOW transaction_isolation`).Scan(&isolation); err != nil { + return err + } + if err := tx.QueryRowContext(ctx, `SELECT bridged_upvote_count FROM `+f.table+` WHERE uri = $1`, f.subject).Scan(&visibleTotal); err != nil { + return err + } + if err := tx.QueryRowContext(ctx, `SELECT now(), clock_timestamp()`).Scan(&transactionNow, &clockBeforeWrite); err != nil { + return err + } + if err := repo.ApplyUpvoteGroupTx(ctx, tx, intent); err != nil { + return err + } + return tx.QueryRowContext(ctx, `SELECT clock_timestamp()`).Scan(&clockAfterWrite) + } + f.apply(t, decorated, 3, 1, f.now.Add(time.Minute), true) + require.True(t, called, "group write must use the aggregate transaction") + require.Equal(t, "read committed", isolation) + require.Equal(t, 3, visibleTotal) + require.Equal(t, 1, f.groupCount(t)) + var recipient, subject, root, reason string + var actor, record sql.NullString + var sortAt time.Time + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT recipient_did, subject_uri, root_post_uri, + reason, actor_did, record_uri, sort_at FROM notifications WHERE reason = 'upvote'`).Scan( + &recipient, &subject, &root, &reason, &actor, &record, &sortAt)) + require.Equal(t, f.recipient, recipient) + require.Equal(t, f.subject, subject) + require.Equal(t, f.root, root) + require.Equal(t, "upvote", reason) + require.False(t, actor.Valid) + require.False(t, record.Valid) + require.Truef(t, clockBeforeWrite.After(transactionNow), + "fixture: clock read %s must follow aggregate transaction start %s", clockBeforeWrite, transactionNow) + requireSortAtWithin(t, sortAt, clockBeforeWrite, clockAfterWrite) + }) + } +} + +func TestBridgedVotesNotifications_NewHighsBumpOldPost(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, "post", "native") + repo := f.repository() + f.nativeUpvote(t) + _, err := f.db.ExecContext(f.ctx, `UPDATE notification_activation SET activated_at = $1`, f.now.Add(-time.Hour)) + require.NoError(t, err) + baseline := f.now.Add(-30 * 24 * time.Hour) + seedStoredAggregate(t, f.ctx, f.db, "posts", f.subject, 3, 0, baseline) + f.seedGroup(t, f.now.Add(-time.Minute)) + f.markSeen(t, repo) + f.unread(t, repo, 0) + first := f.now.Add(-20 * 24 * time.Hour) + f.apply(t, repo, 4, 0, first, true) + requireAggregateStats(t, f.ctx, f.db, "posts", f.subject, expectedAggregateStats{ + nativeUp: 1, bridgedUp: 4, score: 5, asOf: &first, + }) + previous := f.groupSort(t) + require.True(t, previous.After(f.now.Add(-time.Minute))) + f.unread(t, repo, 1) + f.apply(t, repo, 4, 0, first, true) + require.True(t, f.groupSort(t).Equal(previous), "equal total and asOf must not bump") + newer := first.Add(time.Minute) + f.apply(t, repo, 4, 0, newer, true) + require.True(t, f.groupSort(t).Equal(previous), "newer asOf with equal upvotes must not bump") + downOnly := newer.Add(time.Minute) + f.apply(t, repo, 4, 2, downOnly, true) + require.True(t, f.groupSort(t).Equal(previous), "downvotes alone must not bump") + f.after(t, previous) + fallingScore := downOnly.Add(time.Minute) + f.apply(t, repo, 5, 5, fallingScore, true) + requireAggregateStats(t, f.ctx, f.db, "posts", f.subject, expectedAggregateStats{ + nativeUp: 1, bridgedUp: 5, bridgedDown: 5, score: 1, asOf: &fallingScore, + }) + current := f.groupSort(t) + require.True(t, current.After(previous), "an increase bumps despite a falling score") + previous = current + f.after(t, previous) + f.apply(t, repo, 6, 5, fallingScore, true) + current = f.groupSort(t) + require.True(t, current.After(previous), "equal-asOf increase must bump") + previous = current + millisecond := fallingScore.Add(time.Minute).Truncate(time.Millisecond) + storedSameMillisecond := millisecond.Add(800 * time.Microsecond) + f.apply(t, repo, 6, 5, storedSameMillisecond, true) + require.True(t, f.groupSort(t).Equal(previous)) + f.after(t, previous) + olderSameMillisecond := millisecond.Add(100 * time.Microsecond) + f.apply(t, repo, 7, 5, olderSameMillisecond, true) + current = f.groupSort(t) + require.True(t, current.After(previous), "numerically older asOf in the same millisecond must bump") + previous = current + decrease := millisecond.Add(time.Minute) + f.apply(t, repo, 2, 5, decrease, true) + require.True(t, f.groupSort(t).Equal(previous), "decrease must not bump") + f.listedUpvotes(t, repo, 3) + f.markSeen(t, repo) + f.unread(t, repo, 0) + f.after(t, previous) + rebound := decrease.Add(time.Minute) + f.apply(t, repo, 4, 5, rebound, true) + require.True(t, f.groupSort(t).Equal(previous), "rebound below the peak must not bump") + f.unread(t, repo, 0) +} + +func TestBridgedVotesNotifications_DropToZero(t *testing.T) { + for _, native := range []bool{false, true} { + name := "bridged only deletes" + if native { + name = "qualifying native upvote keeps group" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, "post", "native") + repo := f.repository() + if native { + f.nativeUpvote(t) + } + seedStoredAggregate(t, f.ctx, f.db, "posts", f.subject, 3, 0, f.now.Add(-time.Hour)) + f.seedGroup(t, f.now.Add(-time.Minute)) + f.apply(t, repo, 0, 0, f.now, true) + if native { + require.Equal(t, 1, f.groupCount(t)) + require.True(t, f.groupSort(t).Equal(f.now.Add(-time.Minute))) + } else { + require.Equal(t, 0, f.groupCount(t)) + } + }) + } +} + +func TestBridgedVotesNotifications_LaunchBaselineAndStaleAggregate(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, "post", "native") + repo := f.repository() + f.nativeUpvote(t) + baseline := f.now.Add(-time.Hour) + seedStoredAggregate(t, f.ctx, f.db, "posts", f.subject, 5, 1, baseline) + f.apply(t, repo, 5, 1, f.now, true) + require.Equal(t, 0, f.groupCount(t), "unchanged launch baseline is silent") + f.apply(t, repo, 9, 4, baseline, false) + requireAggregateStats(t, f.ctx, f.db, "posts", f.subject, expectedAggregateStats{ + nativeUp: 1, bridgedUp: 5, bridgedDown: 1, score: 5, asOf: &f.now, + }) + require.Equal(t, 0, f.groupCount(t)) + f.apply(t, repo, 6, 1, f.now.Add(time.Minute), true) + require.Equal(t, 1, f.groupCount(t)) + f.listedUpvotes(t, repo, 7) +} + +func TestBridgedVotesNotifications_RecipientAndWithdrawalGates(t *testing.T) { + for _, mode := range []string{"absent", "erased", "aggregator", "bridge", "removed post", "deleted comment root"} { + t.Run(mode, func(t *testing.T) { + t.Parallel() + kind, recipientMode := "post", mode + if mode == "removed post" { + recipientMode = "native" + } + if mode == "deleted comment root" { + kind, recipientMode = "comment", "native" + } + f := newBridgedNotificationFixture(t, kind, recipientMode) + repo := f.repository() + oldSort := f.now.Add(-time.Minute) + withdrawn := mode == "removed post" || mode == "deleted comment root" + if withdrawn { + f.seedGroup(t, oldSort) + f.apply(t, repo, 1, 0, f.now, true) + bumped := f.groupSort(t) + require.True(t, bumped.After(oldSort), "eligible content must bump before withdrawal") + oldSort = bumped + } + if mode == "removed post" { + seedVisibilityAdmission(t, f.db, f.community, f.root, posts.AdmissionStatusRemoved, "", "") + } + if mode == "deleted comment root" { + _, err := f.db.ExecContext(f.ctx, `UPDATE posts SET deleted_at = $2 WHERE uri = $1`, f.root, f.now) + require.NoError(t, err) + } + f.apply(t, repo, 3, 0, f.now.Add(time.Minute), true) + var total int + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT bridged_upvote_count FROM `+f.table+` WHERE uri = $1`, f.subject).Scan(&total)) + require.Equal(t, 3, total) + if withdrawn { + require.Equal(t, 1, f.groupCount(t)) + require.True(t, f.groupSort(t).Equal(oldSort), "withdrawn content must not bump") + } else { + require.Equal(t, 0, f.groupCount(t)) + } + }) + } +} + +func TestBridgedVotesNotifications_NotificationErrorsRollBackAggregate(t *testing.T) { + for _, failure := range []string{"recipient facts", "group write after insert"} { + t.Run(failure, func(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, "post", "native") + repo := f.repository() + baseline := f.now.Add(-time.Hour) + seedStoredAggregate(t, f.ctx, f.db, "posts", f.subject, 2, 1, baseline) + sentinel := errors.New("notification failure") + decorated := bridgedNotificationRepositoryDecorator{Repository: repo} + if failure == "recipient facts" { + decorated.lookups = func(lookups notifications.Lookups) notifications.Lookups { + return bridgedFailRecipientFacts{Lookups: lookups, err: sentinel} + } + } else { + decorated.apply = func(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + require.Equal(t, notifications.UpvoteGroupBump, intent.Action) + require.NoError(t, repo.ApplyUpvoteGroupTx(ctx, tx, intent)) + var count int + require.NoError(t, tx.QueryRowContext(ctx, `SELECT count(*) FROM notifications WHERE recipient_did = $1 AND subject_uri = $2`, f.recipient, f.subject).Scan(&count)) + require.Equal(t, 1, count, "the group write must have occurred before the forced error") + return sentinel + } + } + applied, err := f.store(decorated).ApplyAggregate(f.ctx, bridgedvotes.Aggregate{ + URI: f.subject, Upvotes: 4, Downvotes: 2, AsOf: f.now, + }) + require.ErrorIs(t, err, sentinel) + require.False(t, applied) + requireAggregateStats(t, f.ctx, f.db, "posts", f.subject, expectedAggregateStats{ + bridgedUp: 2, bridgedDown: 1, score: 1, asOf: &baseline, + }) + require.Equal(t, 0, f.groupCount(t), "failed notification rolls back the inserted group") + }) + } +} diff --git a/internal/db/postgres/bridged_votes_peak_migration_test.go b/internal/db/postgres/bridged_votes_peak_migration_test.go new file mode 100644 index 0000000..0593fa7 --- /dev/null +++ b/internal/db/postgres/bridged_votes_peak_migration_test.go @@ -0,0 +1,105 @@ +//go:build integration + +package postgres + +import ( + "testing" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationMigration053_BridgedUpvotePeakColumns(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55)) + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54)) + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53)) + for _, table := range []string{"posts", "comments"} { + var columns int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = $1 AND column_name = 'bridged_upvote_peak'`, table).Scan(&columns)) + require.Equal(t, 0, columns, "%s peak column must be absent after 053 Down", table) + } + + const ( + userDID = "did:plc:peakmigrationuser" + communityDID = "did:plc:peakmigrationcommunity" + postA = "at://did:plc:peakmigrationuser/social.coves.community.postv2/a" + postB = "at://did:plc:peakmigrationuser/social.coves.community.postv2/b" + postNew = "at://did:plc:peakmigrationuser/social.coves.community.postv2/new" + postNull = "at://did:plc:peakmigrationuser/social.coves.community.postv2/null" + commentC = "at://did:plc:peakmigrationuser/social.coves.community.comment/c" + commentNew = "at://did:plc:peakmigrationuser/social.coves.community.comment/new" + commentNull = "at://did:plc:peakmigrationuser/social.coves.community.comment/null" + ) + _, err := db.Exec(`INSERT INTO users (did, handle, pds_url) VALUES ($1, 'peakmigration.test', $2)`, userDID, bridgeAPDSURL) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, created_at) + VALUES ($1, '!peakmigration@local.test', 'peakmigration', $1, $2, $1, NOW())`, communityDID, userDID) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at, bridged_upvote_count) + VALUES ($1, 'bafypeakmigrationa', 'a', $3, $4, 'a', NOW(), 7), + ($2, 'bafypeakmigrationb', 'b', $3, $4, 'b', NOW(), 0)`, postA, postB, userDID, communityDID) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at, bridged_upvote_count) + VALUES ($1, 'bafypeakmigrationc', 'c', $2, $3, 'bafypeakmigrationa', $3, 'bafypeakmigrationa', 'c', NOW(), 4)`, + commentC, userDID, postA) + require.NoError(t, err) + + testkit.MigrateUp(t, db) + for _, row := range []struct { + table string + uri string + peak int + }{ + {"posts", postA, 7}, + {"posts", postB, 0}, + {"comments", commentC, 4}, + } { + var peak int + require.NoError(t, db.QueryRow(`SELECT bridged_upvote_peak FROM `+row.table+` WHERE uri = $1`, row.uri).Scan(&peak)) + require.Equal(t, row.peak, peak) + } + + _, err = db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, 'bafypeakmigrationnewpost', 'new', $2, $3, 'new', NOW())`, postNew, userDID, communityDID) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + VALUES ($1, 'bafypeakmigrationnewcomment', 'new', $2, $3, 'bafypeakmigrationa', $3, 'bafypeakmigrationa', 'new', NOW())`, + commentNew, userDID, postA) + require.NoError(t, err) + for _, row := range []struct { + table string + uri string + }{ + {"posts", postNew}, {"comments", commentNew}, + } { + var peak int + require.NoError(t, db.QueryRow(`SELECT bridged_upvote_peak FROM `+row.table+` WHERE uri = $1`, row.uri).Scan(&peak)) + require.Equal(t, 0, peak) + } + + _, err = db.Exec(`INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at, bridged_upvote_peak) + VALUES ($1, 'bafypeakmigrationnullpost', 'null', $2, $3, 'null', NOW(), NULL)`, postNull, userDID, communityDID) + requireNotificationSQLState(t, err, "23502") + _, err = db.Exec(`INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at, bridged_upvote_peak) + VALUES ($1, 'bafypeakmigrationnullcomment', 'null', $2, $3, 'bafypeakmigrationa', $3, 'bafypeakmigrationa', 'null', NOW(), NULL)`, + commentNull, userDID, postA) + requireNotificationSQLState(t, err, "23502") + for _, row := range []struct { + table string + uri string + }{ + {"posts", postA}, {"comments", commentC}, + } { + _, err = db.Exec(`UPDATE `+row.table+` SET bridged_upvote_peak = NULL WHERE uri = $1`, row.uri) + requireNotificationSQLState(t, err, "23502") + _, err = db.Exec(`UPDATE `+row.table+` SET bridged_upvote_peak = -1 WHERE uri = $1`, row.uri) + requireNotificationSQLState(t, err, "23514") + } +} diff --git a/internal/db/postgres/bridged_votes_peak_test.go b/internal/db/postgres/bridged_votes_peak_test.go new file mode 100644 index 0000000..f0303e8 --- /dev/null +++ b/internal/db/postgres/bridged_votes_peak_test.go @@ -0,0 +1,177 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/bridgedvotes" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + + "github.com/stretchr/testify/require" +) + +func (f bridgedNotificationFixture) upvotePeak(t *testing.T) int { + t.Helper() + var peak int + require.NoError(t, f.db.QueryRowContext(f.ctx, + `SELECT bridged_upvote_peak FROM `+f.table+` WHERE uri = $1`, f.subject).Scan(&peak)) + return peak +} + +func TestBridgedVotesPeak_OscillationBumpsOnlyAboveHighWaterMark(t *testing.T) { + for _, kind := range []string{"post", "comment"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, kind, "native") + repo := f.repository() + require.Equal(t, 0, f.groupCount(t)) + require.Equal(t, 0, f.upvotePeak(t)) + f.nativeUpvote(t) + f.apply(t, repo, 5, 0, f.now.Add(time.Minute), true) + require.Equal(t, 1, f.groupCount(t)) + f.markSeen(t, repo) + f.unread(t, repo, 0) + sortAt := f.groupSort(t) + + for index, step := range []struct { + total int + listedUpvotes int + }{ + {total: 3, listedUpvotes: 4}, + {total: 5, listedUpvotes: 6}, + {total: 4, listedUpvotes: 5}, + {total: 5, listedUpvotes: 6}, + } { + f.after(t, sortAt) + f.apply(t, repo, step.total, 0, f.now.Add(time.Duration(index+2)*time.Minute), true) + require.True(t, f.groupSort(t).Equal(sortAt), "total %d must preserve the previous group sort", step.total) + f.unread(t, repo, 0) + f.listedUpvotes(t, repo, step.listedUpvotes) + require.Equal(t, 5, f.upvotePeak(t)) + } + + f.after(t, sortAt) + f.apply(t, repo, 6, 0, f.now.Add(6*time.Minute), true) + require.True(t, f.groupSort(t).After(sortAt)) + f.unread(t, repo, 1) + require.Equal(t, 6, f.upvotePeak(t)) + }) + } +} + +func TestBridgedVotesPeak_GroupDeletedAtZeroReturnsOnlyAbovePeak(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, "post", "native") + repo := f.repository() + seedStoredAggregate(t, f.ctx, f.db, "posts", f.subject, 3, 0, f.now.Add(-time.Hour)) + _, err := f.db.ExecContext(f.ctx, `UPDATE posts SET bridged_upvote_peak = 3 WHERE uri = $1`, f.subject) + require.NoError(t, err) + f.seedGroup(t, f.now.Add(-time.Minute)) + + f.apply(t, repo, 0, 0, f.now, true) + require.Equal(t, 0, f.groupCount(t)) + require.Equal(t, 3, f.upvotePeak(t)) + f.apply(t, repo, 3, 0, f.now.Add(time.Minute), true) + require.Equal(t, 0, f.groupCount(t)) + require.Equal(t, 3, f.upvotePeak(t)) + f.apply(t, repo, 4, 0, f.now.Add(2*time.Minute), true) + require.Equal(t, 1, f.groupCount(t)) + var root string + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT root_post_uri FROM notifications + WHERE recipient_did = $1 AND subject_uri = $2 AND reason = 'upvote'`, f.recipient, f.subject).Scan(&root)) + require.Equal(t, f.root, root) + f.unread(t, repo, 1) + require.Equal(t, 4, f.upvotePeak(t)) +} + +func TestBridgedVotesPeak_Bookkeeping(t *testing.T) { + for _, row := range []struct { + name string + recipientMode string + storedPeak int + incoming int + stale bool + unwired bool + groupFails bool + communityRemoved bool + wantApplied bool + wantTotal int + wantPeak int + }{ + {name: "community removed", recipientMode: "native", storedPeak: 3, incoming: 6, communityRemoved: true, wantApplied: true, wantTotal: 6, wantPeak: 6}, + {name: "erased recipient", recipientMode: "erased", storedPeak: 3, incoming: 6, wantApplied: true, wantTotal: 6, wantPeak: 6}, + {name: "notifications not wired", recipientMode: "native", storedPeak: 3, incoming: 6, unwired: true, wantApplied: true, wantTotal: 6, wantPeak: 6}, + {name: "stale asOf", recipientMode: "native", storedPeak: 3, incoming: 6, stale: true, wantTotal: 3, wantPeak: 3}, + {name: "decrease", recipientMode: "native", storedPeak: 5, incoming: 2, wantApplied: true, wantTotal: 2, wantPeak: 5}, + {name: "group write fails", recipientMode: "native", storedPeak: 3, incoming: 6, groupFails: true, wantTotal: 3, wantPeak: 3}, + } { + t.Run(row.name, func(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, "post", row.recipientMode) + baseline := f.now.Add(-time.Hour) + seedStoredAggregate(t, f.ctx, f.db, "posts", f.subject, 3, 0, baseline) + _, err := f.db.ExecContext(f.ctx, `UPDATE posts SET bridged_upvote_peak = $2 WHERE uri = $1`, f.subject, row.storedPeak) + require.NoError(t, err) + if row.communityRemoved { + seedVisibilityAdmission(t, f.db, f.community, f.root, posts.AdmissionStatusRemoved, "", "") + } + + repo := f.repository() + store := f.store(repo) + var sentinel error + if row.unwired { + store = NewBridgedVotesRepository(f.db) + } + if row.groupFails { + sentinel = errors.New("group write failed") + store = f.store(bridgedNotificationRepositoryDecorator{Repository: repo, + apply: func(context.Context, *sql.Tx, notifications.UpvoteGroupIntent) error { return sentinel }, + }) + } + asOf := f.now.Add(time.Minute) + if row.stale { + asOf = baseline.Add(-time.Second) + } + applied, err := store.ApplyAggregate(f.ctx, bridgedvotes.Aggregate{URI: f.subject, Upvotes: row.incoming, AsOf: asOf}) + if row.groupFails { + require.ErrorIs(t, err, sentinel) + require.False(t, applied) + } else { + require.NoError(t, err) + require.Equal(t, row.wantApplied, applied) + } + var total int + require.NoError(t, f.db.QueryRowContext(f.ctx, `SELECT bridged_upvote_count FROM posts WHERE uri = $1`, f.subject).Scan(&total)) + require.Equal(t, row.wantTotal, total) + require.Equal(t, row.wantPeak, f.upvotePeak(t)) + require.Equal(t, 0, f.groupCount(t)) + }) + } +} + +func TestBridgedVotesPeak_CountWrittenWithoutPeak(t *testing.T) { + for _, kind := range []string{"post", "comment"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + f := newBridgedNotificationFixture(t, kind, "native") + repo := f.repository() + seedStoredAggregate(t, f.ctx, f.db, f.table, f.subject, 5, 0, f.now.Add(-time.Hour)) + require.Equal(t, 0, f.groupCount(t)) + f.apply(t, repo, 3, 0, f.now.Add(time.Minute), true) + require.Equal(t, 0, f.groupCount(t)) + require.Equal(t, 5, f.upvotePeak(t)) + f.apply(t, repo, 5, 0, f.now.Add(2*time.Minute), true) + require.Equal(t, 0, f.groupCount(t)) + require.Equal(t, 5, f.upvotePeak(t)) + f.apply(t, repo, 6, 0, f.now.Add(3*time.Minute), true) + require.Equal(t, 1, f.groupCount(t)) + require.Equal(t, 6, f.upvotePeak(t)) + }) + } +} diff --git a/internal/db/postgres/bridged_votes_repo.go b/internal/db/postgres/bridged_votes_repo.go index de44cae..57a566c 100644 --- a/internal/db/postgres/bridged_votes_repo.go +++ b/internal/db/postgres/bridged_votes_repo.go @@ -9,6 +9,7 @@ import ( "time" "Coves/internal/core/bridgedvotes" + "Coves/internal/core/notifications" "github.com/lib/pq" ) @@ -16,12 +17,36 @@ import ( // BridgedVotesRepository implements bridgedvotes.Store over posts, comments and // communities. type BridgedVotesRepository struct { - db *sql.DB + db *sql.DB + notificationRepo notifications.Repository + bridgeHosts notifications.BridgeHostChecker +} + +// BridgedVotesRepositoryOption configures a BridgedVotesRepository. +type BridgedVotesRepositoryOption func(*BridgedVotesRepository) + +// WithBridgedVoteNotifications wires upvote-group fan-out into aggregate writes. +// A nil repository leaves notifications unwired. +func WithBridgedVoteNotifications(repo notifications.Repository, bridgeHosts notifications.BridgeHostChecker) BridgedVotesRepositoryOption { + return func(r *BridgedVotesRepository) { + r.notificationRepo = repo + r.bridgeHosts = bridgeHosts + } } // NewBridgedVotesRepository builds the postgres-backed bridgedvotes.Store. -func NewBridgedVotesRepository(db *sql.DB) *BridgedVotesRepository { - return &BridgedVotesRepository{db: db} +func NewBridgedVotesRepository(db *sql.DB, options ...BridgedVotesRepositoryOption) *BridgedVotesRepository { + r := &BridgedVotesRepository{db: db} + for _, option := range options { + option(r) + } + return r +} + +// NotificationWiring returns the notification repository and bridge checker +// supplied to this store, or nil values if they were not configured. +func (r *BridgedVotesRepository) NotificationWiring() (notifications.Repository, notifications.BridgeHostChecker) { + return r.notificationRepo, r.bridgeHosts } // SelectCandidates implements bridgedvotes.Store: it selects the oldest eligible subjects in poll-rotation order. @@ -117,7 +142,9 @@ func (r *BridgedVotesRepository) DistinctCommunityPDSURLs(ctx context.Context) ( return urls, nil } -// ApplyAggregate implements bridgedvotes.Store: it applies a non-regressing bridged tally to its post or comment. +// ApplyAggregate implements bridgedvotes.Store: it applies a non-regressing +// bridged tally and its upvote-group intent in one transaction. The subject +// lock precedes the group write and any recipient foreign-key check. func (r *BridgedVotesRepository) ApplyAggregate(ctx context.Context, agg bridgedvotes.Aggregate) (bool, error) { if agg.AsOf.IsZero() { // The client never produces one (ParseAsOf rejects the zero time), so @@ -126,21 +153,46 @@ func (r *BridgedVotesRepository) ApplyAggregate(ctx context.Context, agg bridged return false, fmt.Errorf("apply bridged vote aggregate to %q: %w", agg.URI, bridgedvotes.ErrMissingAsOf) } + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return false, fmt.Errorf("begin bridged vote aggregate transaction: %w", err) + } + defer func() { + if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + slog.Warn("failed to roll back bridged vote aggregate transaction", "error", rollbackErr) + } + }() + + // The subject lock precedes the group row and users FK, as in erasure + // and native vote writes. The poller has no actor, so it takes no erasure + // advisory lock. + subject, found, err := lockBridgedAggregateSubject(ctx, tx, agg.URI) + if err != nil { + return false, err + } + if !found { + return false, nil + } + // Jetstream record stamps and this poller race through the same bridged columns. // Keeping the >= guard, count replacement, and score recomputation in one UPDATE // prevents a read-then-write race from letting an older aggregate overwrite a newer // one or recomputing score from counts that did not win the guard. // + // The same UPDATE raises bridged_upvote_peak. bridged_upvote_count is inside + // GREATEST because the record channel can write a count without raising the + // peak, so the pre-update stored count is absorbed into the peak here. + // // The guard truncates both sides to milliseconds. The bridge serializes the // aggregate channel's updatedAt to milliseconds and its record stamps to // microseconds, so the same sampling instant arrives here up to 999 µs // "older" than what the record channel stored. Comparing at the coarser // precision keeps an equal instant idempotent across both channels, which // is the contract; a genuinely older aggregate still loses. - for _, table := range []string{"posts", "comments"} { - result, err := r.db.ExecContext(ctx, ` - UPDATE `+table+` + result, err := tx.ExecContext(ctx, ` + UPDATE `+subject.table+` SET bridged_upvote_count = $2, + bridged_upvote_peak = GREATEST(bridged_upvote_peak, bridged_upvote_count, $2), bridged_downvote_count = $3, bridged_stats_as_of = $4, score = (upvote_count + $2) - (downvote_count + $3) @@ -149,28 +201,68 @@ func (r *BridgedVotesRepository) ApplyAggregate(ctx context.Context, agg bridged AND (bridged_stats_as_of IS NULL OR date_trunc('milliseconds', $4::timestamptz) >= date_trunc('milliseconds', bridged_stats_as_of)) `, agg.URI, agg.Upvotes, agg.Downvotes, agg.AsOf) + if err != nil { + return false, fmt.Errorf("failed to apply bridged vote aggregate to %s: %w", subject.table, err) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("failed to check bridged vote %s aggregate result: %w", subject.table, err) + } + if rowsAffected == 0 { + // The record channel may already have applied a newer sample. + slog.Debug("bridged vote aggregate matched no writable subject", + "uri", agg.URI, "incoming_as_of", agg.AsOf) + return false, nil + } + if r.notificationRepo != nil { + intent, err := notifications.FanoutBridgedUpvoteChange(ctx, r.notificationRepo.LookupsTx(tx), r.bridgeHosts, + notifications.BridgedUpvoteChange{ + SubjectURI: agg.URI, SubjectRootURI: subject.rootURI, + PreviousUpvotes: subject.previousUpvotes, Upvotes: agg.Upvotes, PeakUpvotes: subject.peakUpvotes, + }) if err != nil { - return false, fmt.Errorf("failed to apply bridged vote aggregate to %s: %w", table, err) - } - rowsAffected, err := result.RowsAffected() - if err != nil { - return false, fmt.Errorf("failed to check bridged vote %s aggregate result: %w", table, err) + return false, fmt.Errorf("fan out bridged vote aggregate notifications: %w", err) } - if rowsAffected > 0 { - return true, nil + if err := r.notificationRepo.ApplyUpvoteGroupTx(ctx, tx, intent); err != nil { + return false, fmt.Errorf("apply bridged vote aggregate upvote group: %w", err) } } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("commit bridged vote aggregate transaction: %w", err) + } + return true, nil +} + +type bridgedAggregateSubject struct { + table string + rootURI string + previousUpvotes int + peakUpvotes int +} - // The poller selected this subject as existing and non-deleted moments ago, - // so zero rows in both tables is the stale-guard case: a stored stamp newer - // than what the bridge just served, usually the record channel arriving - // first. That is expected in the steady state and is counted in the sweep - // report rather than logged per subject; the per-URI detail stays at debug. - slog.Debug("bridged vote aggregate matched no writable subject", - "uri", agg.URI, - "incoming_as_of", agg.AsOf, - ) - return false, nil +// lockBridgedAggregateSubject reads the previous tally and peak under the +// subject row lock, trying posts before comments and excluding deleted subjects. +func lockBridgedAggregateSubject(ctx context.Context, tx *sql.Tx, uri string) (bridgedAggregateSubject, bool, error) { + var subject bridgedAggregateSubject + err := tx.QueryRowContext(ctx, + `SELECT bridged_upvote_count, bridged_upvote_peak FROM posts WHERE uri = $1 AND deleted_at IS NULL FOR UPDATE`, uri).Scan(&subject.previousUpvotes, &subject.peakUpvotes) + if err == nil { + subject.table = "posts" + return subject, true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return bridgedAggregateSubject{}, false, fmt.Errorf("lock bridged vote aggregate subject in posts: %w", err) + } + err = tx.QueryRowContext(ctx, + `SELECT bridged_upvote_count, bridged_upvote_peak, root_uri FROM comments WHERE uri = $1 AND deleted_at IS NULL FOR UPDATE`, uri).Scan(&subject.previousUpvotes, &subject.peakUpvotes, &subject.rootURI) + if err == nil { + subject.table = "comments" + return subject, true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return bridgedAggregateSubject{}, false, fmt.Errorf("lock bridged vote aggregate subject in comments: %w", err) + } + return bridgedAggregateSubject{}, false, nil } // MarkPolled implements bridgedvotes.Store: it advances rotation watermarks for every attempted subject. diff --git a/internal/db/postgres/community_subscriber_recount_migration_test.go b/internal/db/postgres/community_subscriber_recount_migration_test.go index d3f0219..9efe5c9 100644 --- a/internal/db/postgres/community_subscriber_recount_migration_test.go +++ b/internal/db/postgres/community_subscriber_recount_migration_test.go @@ -22,6 +22,12 @@ func TestMigration045RecountsAndMaintainsCommunitySubscribers(t *testing.T) { t.Parallel() db := testkit.DB(t) + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), diff --git a/internal/db/postgres/credential_reencrypt_migration_test.go b/internal/db/postgres/credential_reencrypt_migration_test.go index dbd1c81..eede460 100644 --- a/internal/db/postgres/credential_reencrypt_migration_test.go +++ b/internal/db/postgres/credential_reencrypt_migration_test.go @@ -27,6 +27,12 @@ func TestMigration046DownRestoresUsableEncryptionKey(t *testing.T) { db := testkit.DB(t) assert.False(t, credentialReencryptKeyTable(t, db).Valid, "migration 046 Up must drop encryption_keys before its Down behavior can be tested") + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), @@ -401,6 +407,12 @@ func TestCredentialReencryptRejectsLegacyRowsAfterEncryptionKeysDropped(t *testi func credentialReencryptVersion45Database(t *testing.T) *sql.DB { t.Helper() db := testkit.DB(t) + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), diff --git a/internal/db/postgres/discover_hot_migration_test.go b/internal/db/postgres/discover_hot_migration_test.go index 6dffd2c..2e5d610 100644 --- a/internal/db/postgres/discover_hot_migration_test.go +++ b/internal/db/postgres/discover_hot_migration_test.go @@ -35,6 +35,12 @@ func TestMigration048DiscoverHotSnapshots(t *testing.T) { _, err := db.ExecContext(ctx, `CREATE TABLE discover_hot_migration_sentinel (id BIGINT PRIMARY KEY)`) require.NoError(t, err) + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), diff --git a/internal/db/postgres/future_comment_created_at_migration_test.go b/internal/db/postgres/future_comment_created_at_migration_test.go index 51c7c6c..c2adfa9 100644 --- a/internal/db/postgres/future_comment_created_at_migration_test.go +++ b/internal/db/postgres/future_comment_created_at_migration_test.go @@ -18,6 +18,12 @@ func TestMigration041_ClampsFutureCommentCreatedAt(t *testing.T) { t.Parallel() db := testkit.DB(t) + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), diff --git a/internal/db/postgres/notification_apply_batch_test.go b/internal/db/postgres/notification_apply_batch_test.go new file mode 100644 index 0000000..a59a76e --- /dev/null +++ b/internal/db/postgres/notification_apply_batch_test.go @@ -0,0 +1,177 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationRepository_ApplyTx_OneInsertForManyMentions(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + actorDID := "did:plc:" + uniqueID + "actor" + recordURI := "at://" + actorDID + "/social.coves.community.comment/mention" + rootPostURI := "at://" + actorDID + "/social.coves.community.postv2/root" + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + intents := make([]notifications.Intent, 100) + for index := range intents { + recipientDID := fmt.Sprintf("did:plc:%s%03d", uniqueID, index) + _, err := db.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + recipientDID, fmt.Sprintf("%s%03d.test", uniqueID, index), "https://native.pds.test") + require.NoError(t, err, "index recipient %d", index) + intents[index] = notifications.Intent{ + Reason: notifications.ReasonMention, RecipientDID: recipientDID, ActorDID: actorDID, + RecordURI: recordURI, RecordCID: "bafyrenotificationbatch", RootPostURI: rootPostURI, + RecordCreatedAt: createdAt, + } + } + + transaction, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, NewNotificationRepository(db).ApplyTx(ctx, transaction, intents)) + + // An inserted tuple's xmin identifies its writing subtransaction, and cmin + // identifies the INSERT command. Inspect both before ending this transaction. + var rowCount, writingTransactions, insertCommands int + require.NoError(t, transaction.QueryRowContext(ctx, ` + SELECT count(*), count(DISTINCT xmin::text), count(DISTINCT cmin::text) + FROM notifications WHERE record_uri = $1`, recordURI, + ).Scan(&rowCount, &writingTransactions, &insertCommands)) + require.Equal(t, 100, rowCount) + require.Equal(t, 1, writingTransactions, "all mention rows must be written in one savepoint") + require.Equal(t, 1, insertCommands, "all mention rows must be written by one INSERT") + require.NoError(t, transaction.Commit()) +} + +func TestNotificationRepository_ApplyTx_MissingRecipientDoesNotAbortOtherIntents(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + actorDID := "did:plc:" + uniqueID + "actor" + recordURI := "at://" + actorDID + "/social.coves.community.comment/mention" + rootPostURI := "at://" + actorDID + "/social.coves.community.postv2/root" + firstRecipient := "did:plc:" + uniqueID + "b" + missingRecipient := "did:plc:" + uniqueID + "x" + lastRecipient := "did:plc:" + uniqueID + "d" + for _, recipient := range []struct{ did, handle string }{ + {firstRecipient, uniqueID + "b.test"}, + {lastRecipient, uniqueID + "d.test"}, + } { + _, err := db.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + recipient.did, recipient.handle, "https://native.pds.test") + require.NoError(t, err) + } + intents := make([]notifications.Intent, 0, 3) + for _, did := range []string{firstRecipient, missingRecipient, lastRecipient} { + intents = append(intents, notifications.Intent{ + Reason: notifications.ReasonMention, RecipientDID: did, ActorDID: actorDID, + RecordURI: recordURI, RecordCID: "bafyrenotificationbatch", RootPostURI: rootPostURI, + RecordCreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }) + } + + transaction, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + // The sentinel stands for the caller's earlier writes in the index + // transaction, which the fallback's rollback of the batch must not discard. + sentinelDID := "did:plc:" + uniqueID + "s" + _, err = transaction.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + sentinelDID, uniqueID+"s.test", "https://native.pds.test") + require.NoError(t, err) + var transactionTime, clockBeforeWrite time.Time + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT now(), clock_timestamp()`). + Scan(&transactionTime, &clockBeforeWrite)) + require.Truef(t, clockBeforeWrite.After(transactionTime), + "fixture: clock_timestamp() %s must be after now() %s so a now()-stamped row fails the bound", + clockBeforeWrite, transactionTime) + require.NoError(t, NewNotificationRepository(db).ApplyTx(ctx, transaction, intents), + "only the missing recipient's foreign-key violation may be skipped") + clockAfterWrite := clockTimestamp(t, ctx, transaction) + var sentinelInTransaction bool + require.NoError(t, transaction.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM users WHERE did = $1)`, sentinelDID).Scan(&sentinelInTransaction)) + require.True(t, sentinelInTransaction, + "the batch rollback must undo only the batch, not the caller's earlier writes in the transaction") + rows, err := transaction.QueryContext(ctx, + `SELECT recipient_did, sort_at FROM notifications WHERE record_uri = $1 ORDER BY recipient_did`, recordURI) + require.NoError(t, err) + var recipients []string + for rows.Next() { + var did string + var sortAt time.Time + require.NoError(t, rows.Scan(&did, &sortAt)) + // The surviving rows come from the per-intent fallback INSERT. + requireSortAtWithin(t, sortAt, clockBeforeWrite, clockAfterWrite) + recipients = append(recipients, did) + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.Equal(t, []string{firstRecipient, lastRecipient}, recipients, + "the missing recipient between two valid recipients must leave both valid inserts intact") + var answer int + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT 1`).Scan(&answer), + "the transaction must remain usable after the foreign-key violation") + require.Equal(t, 1, answer) + require.NoError(t, transaction.Commit()) + var sentinelCommitted bool + require.NoError(t, db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM users WHERE did = $1)`, sentinelDID).Scan(&sentinelCommitted)) + require.True(t, sentinelCommitted, "the caller's earlier write must commit with the notifications") +} + +func TestNotificationRepository_ApplyTx_DuplicateIntentInOneCall(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + recipientDID := "did:plc:" + uniqueID + "recipient" + actorDID := "did:plc:" + uniqueID + "actor" + createTestUser(t, db, uniqueID+".test", recipientDID) + intent := notifications.Intent{ + Reason: notifications.ReasonMention, RecipientDID: recipientDID, ActorDID: actorDID, + RecordURI: "at://" + actorDID + "/social.coves.community.comment/mention", + RecordCID: "bafyrenotificationbatch", RootPostURI: "at://" + actorDID + "/social.coves.community.postv2/root", + RecordCreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + } + transaction, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, NewNotificationRepository(db).ApplyTx(ctx, transaction, []notifications.Intent{intent, intent})) + var rowCount int + require.NoError(t, transaction.QueryRowContext(ctx, + `SELECT count(*) FROM notifications WHERE record_uri = $1`, intent.RecordURI).Scan(&rowCount)) + require.Equal(t, 1, rowCount, "duplicate intents in one call must insert only one notification") + require.NoError(t, transaction.Commit()) +} + +func TestNotificationRepository_ApplyTx_NilIntents(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + db := testkit.DB(t) + transaction, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, NewNotificationRepository(db).ApplyTx(ctx, transaction, nil)) + require.NoError(t, transaction.Commit()) +} diff --git a/internal/db/postgres/notification_bridged_gate_scope_test.go b/internal/db/postgres/notification_bridged_gate_scope_test.go new file mode 100644 index 0000000..b3bcd04 --- /dev/null +++ b/internal/db/postgres/notification_bridged_gate_scope_test.go @@ -0,0 +1,205 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func bridgedScopeGroup(t *testing.T, fixture *unreadVisibilityFixture, subject, root string, total int) int64 { + t.Helper() + kind := "post" + if subject != root { + kind = "comment" + } + setBridgedGroupTotals(t, fixture.db, kind, subject, total, 0) + var id int64 + require.NoError(t, fixture.db.QueryRow(`INSERT INTO notifications + (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $3, $4) RETURNING id`, fixture.recipient, subject, root, + fixture.sortAt.Add(time.Second)).Scan(&id)) + return id +} + +func bridgedScopeRepository(fixture *unreadVisibilityFixture, enabled bool) notifications.Repository { + if enabled { + return NewNotificationRepository(fixture.db, WithBridgedUpvoteTotals()) + } + return NewNotificationRepository(fixture.db) +} + +func TestNotificationRepository_BridgedGateOffIgnoresTotals(t *testing.T) { + t.Parallel() + for _, operation := range []string{"delete_if_empty", "sweep", "count", "list", "mixed_list"} { + t.Run(operation, func(t *testing.T) { + t.Parallel() + fixture := newUnreadVisibilityFixture(t) + subject := fixture.post(t, posts.AdmissionStatusAccepted, false) + id := bridgedScopeGroup(t, fixture, subject, subject, 5) + repository := bridgedScopeRepository(fixture, false) + switch operation { + case "delete_if_empty": + transaction, err := fixture.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, repository.ApplyUpvoteGroupTx(context.Background(), transaction, deleteIfEmptyIntent(fixture.recipient, subject))) + require.NoError(t, transaction.Commit()) + case "sweep": + deleted, err := retentionSweeper(t, fixture.db).SweepEmptyUpvoteGroups(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 1, deleted) + case "count", "list": + var stored int + require.NoError(t, fixture.db.QueryRow(`SELECT count(*) FROM notifications WHERE id = $1`, id).Scan(&stored)) + require.Equal(t, 1, stored, "the hidden group must remain stored") + reader := repository.(notifications.ReadRepository) + if operation == "count" { + count, err := reader.CountUnread(context.Background(), fixture.recipient) + require.NoError(t, err) + require.Equal(t, 1, count, "only the fixture's reply is unread") + } else { + page, err := reader.List(context.Background(), fixture.recipient, "", 10) + require.NoError(t, err) + require.Len(t, page.Notifications, 1, "only the fixture's reply is listed") + require.Equal(t, notifications.ReasonCommentReply, page.Notifications[0].Reason) + } + case "mixed_list": + votes := qualifyingUpvoteFixture{db: fixture.db} + votes.insertVote(t, "did:plc:"+testkit.UniqueID(t), subject, "up", fixture.sortAt, false) + votes.insertVote(t, "did:plc:"+testkit.UniqueID(t), subject, "up", fixture.sortAt, false) + page, err := repository.(notifications.ReadRepository).List(context.Background(), fixture.recipient, "", 10) + require.NoError(t, err) + require.Len(t, page.Notifications, 2) + require.Equal(t, subject, page.Notifications[0].SubjectURI) + require.Equal(t, 2, page.Notifications[0].UpvoteCount) + } + if operation == "delete_if_empty" || operation == "sweep" { + var remaining int + require.NoError(t, fixture.db.QueryRow(`SELECT count(*) FROM notifications WHERE id = $1`, id).Scan(&remaining)) + require.Equal(t, 0, remaining) + } + }) + } +} + +func TestNotificationRepository_BridgedGateOnDoesNotFilterCommunityHost(t *testing.T) { + t.Parallel() + for _, operation := range []string{"delete_if_empty", "sweep", "count", "list"} { + t.Run(operation, func(t *testing.T) { + t.Parallel() + fixture := newUnreadVisibilityFixture(t) + result, err := fixture.db.Exec(`UPDATE communities SET pds_url = $2 WHERE did = $1`, fixture.community, "https://unrelated.example.test") + require.NoError(t, err) + updated, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, updated, "the subject community must use the unrelated host") + subject := fixture.post(t, posts.AdmissionStatusAccepted, false) + id := bridgedScopeGroup(t, fixture, subject, subject, 6) + repository := bridgedScopeRepository(fixture, true) + switch operation { + case "delete_if_empty": + transaction, err := fixture.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, repository.ApplyUpvoteGroupTx(context.Background(), transaction, deleteIfEmptyIntent(fixture.recipient, subject))) + require.NoError(t, transaction.Commit()) + case "sweep": + deleted, err := bridgedRetentionSweeper(t, fixture.db).SweepEmptyUpvoteGroups(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 0, deleted) + case "count": + count, err := repository.(notifications.ReadRepository).CountUnread(context.Background(), fixture.recipient) + require.NoError(t, err) + require.Equal(t, 2, count, "reply and bridged group must both count") + case "list": + page, err := repository.(notifications.ReadRepository).List(context.Background(), fixture.recipient, "", 10) + require.NoError(t, err) + require.Len(t, page.Notifications, 2, "reply and bridged group must both be listed") + require.Equal(t, subject, page.Notifications[0].SubjectURI) + require.Equal(t, 6, page.Notifications[0].UpvoteCount) + } + var remaining int + require.NoError(t, fixture.db.QueryRow(`SELECT count(*) FROM notifications WHERE id = $1`, id).Scan(&remaining)) + require.Equal(t, 1, remaining, "the group must remain stored") + }) + } +} + +func TestNotificationRepository_BridgedGatePreservesVisibilityRestrictions(t *testing.T) { + t.Parallel() + for _, restriction := range []string{"pending_post", "hidden_comment_root", "disabled_upvote"} { + for _, read := range []string{"count", "list"} { + t.Run(restriction+"/"+read, func(t *testing.T) { + t.Parallel() + fixture := newUnreadVisibilityFixture(t) + control := fixture.post(t, posts.AdmissionStatusAccepted, false) + bridgedScopeGroup(t, fixture, control, control, 5) + subject, root := "", "" + var controlRecipient string + switch restriction { + case "pending_post": + subject = fixture.post(t, posts.AdmissionStatusPending, false) + root = subject + case "hidden_comment_root": + root = fixture.post(t, posts.AdmissionStatusPending, false) + subject = seedActorComment(t, fixture.db, fixture.recipient, root, testkit.TID(), fixture.sortAt) + case "disabled_upvote": + fixture.setDisabledReasons(t, fixture.recipient, []string{"upvote"}) + // A reason-wide preference cannot leave an upvote visible to this recipient. + // Use a second recipient in the same clone as the enabled control. + controlRecipient = retentionUser(t, fixture.db) + retentionSeenAt(t, fixture.db, controlRecipient, fixture.sortAt.Add(-time.Microsecond)) + _, err := fixture.db.Exec(`UPDATE notifications SET recipient_did = $2 WHERE subject_uri = $1`, control, controlRecipient) + require.NoError(t, err) + subject = fixture.post(t, posts.AdmissionStatusAccepted, false) + root = subject + } + id := bridgedScopeGroup(t, fixture, subject, root, 5) + var stored int + require.NoError(t, fixture.db.QueryRow(`SELECT count(*) FROM notifications WHERE id = $1`, id).Scan(&stored)) + require.Equal(t, 1, stored, "the restricted group must still be in storage") + reader := bridgedScopeRepository(fixture, true).(notifications.ReadRepository) + if read == "count" { + if controlRecipient != "" { + controlCount, err := reader.CountUnread(context.Background(), controlRecipient) + require.NoError(t, err) + require.Equal(t, 1, controlCount, "the enabled recipient counts the bridged control") + } + count, err := reader.CountUnread(context.Background(), fixture.recipient) + require.NoError(t, err) + if restriction == "disabled_upvote" { + require.Equal(t, 1, count, "only the reply counts when upvotes are disabled") + } else { + require.Equal(t, 2, count, "the reply and visible bridged control count, but not the restricted group") + } + } else { + if controlRecipient != "" { + controlPage, err := reader.List(context.Background(), controlRecipient, "", 10) + require.NoError(t, err) + require.Len(t, controlPage.Notifications, 1, "the enabled recipient lists the bridged control") + require.Equal(t, control, controlPage.Notifications[0].SubjectURI) + } + page, err := reader.List(context.Background(), fixture.recipient, "", 10) + require.NoError(t, err) + if restriction == "disabled_upvote" { + require.Len(t, page.Notifications, 1, "disabled upvotes are hidden; reply remains") + require.Equal(t, notifications.ReasonCommentReply, page.Notifications[0].Reason) + } else { + require.Len(t, page.Notifications, 2, "reply and visible bridged control are listed") + require.Equal(t, control, page.Notifications[0].SubjectURI) + require.Equal(t, 5, page.Notifications[0].UpvoteCount) + } + } + }) + } + } +} diff --git a/internal/db/postgres/notification_bridged_upvote_plans_test.go b/internal/db/postgres/notification_bridged_upvote_plans_test.go new file mode 100644 index 0000000..7e5feff --- /dev/null +++ b/internal/db/postgres/notification_bridged_upvote_plans_test.go @@ -0,0 +1,249 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "strconv" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func bridgedCountPlan(t *testing.T, db *sql.DB, statement string, args ...any) (map[string]any, []byte) { + t.Helper() + var raw []byte + require.NoError(t, db.QueryRowContext(context.Background(), "EXPLAIN (ANALYZE, FORMAT JSON) "+statement, args...).Scan(&raw)) + var plans []struct { + Plan map[string]any `json:"Plan"` + } + require.NoError(t, json.Unmarshal(raw, &plans)) + require.Len(t, plans, 1) + return plans[0].Plan, raw +} + +// This is a gate-on guard of the existing seen-at access-path contract; it can +// pass while the builder still ignores the gate. +func TestNotificationUnreadCount_BridgedSeenAtBoundsRecipientIndexScan(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := f.sortAt + seenAt := base.Add(10000 * time.Microsecond) + retentionSeenAt(t, f.db, f.recipient, seenAt) + prefix := "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + "-" + insertUnreadCountRows(t, f.db, f.recipient, f.actor, prefix, f.root, f.root, base, 10000, "none") + insertUnreadCountRows(t, f.db, f.recipient, f.actor, prefix+"unread-", f.root, f.root, seenAt, 5, "none") + for _, table := range []string{"notifications", "notification_state", "posts", "community_post_admissions", "comments", "notification_public_post_withdrawals"} { + _, err := f.db.ExecContext(context.Background(), "ANALYZE "+table) + require.NoError(t, err) + } + plan, raw := bridgedCountPlan(t, f.db, buildCountUnreadNotificationsSQL(true), f.recipient) + node := unreadCountPlanIndexNode(plan, "idx_notifications_recipient_sort") + require.NotNil(t, node, "EXPLAIN plan: %s", raw) + indexCond, _ := node["Index Cond"].(string) + require.Contains(t, indexCond, "sort_at", "EXPLAIN plan: %s", raw) + actualRows, _ := node["Actual Rows"].(float64) + actualLoops, _ := node["Actual Loops"].(float64) + require.LessOrEqual(t, actualRows*actualLoops, float64(5), "rows read from recipient index; EXPLAIN plan: %s", raw) +} + +func bridgedListCountingRepository(t *testing.T, source *sql.DB) (notifications.ReadRepository, *listStatementCounter) { + t.Helper() + var database string + require.NoError(t, source.QueryRowContext(context.Background(), `SELECT current_database()`).Scan(&database)) + connector, err := pq.NewConnector(testkit.Endpoints().Postgres.URL(database)) + require.NoError(t, err) + counter := &listStatementCounter{} + db := sql.OpenDB(&listCountingConnector{underlying: connector, counter: counter}) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + return NewNotificationRepository(db, WithBridgedUpvoteTotals()).(notifications.ReadRepository), counter +} + +func TestNotificationList_BridgedAggregatesUseOneBoundedStatement(t *testing.T) { + t.Parallel() + statementTotals := make([]int, 0, 2) + for _, size := range []int{50, 8} { + t.Run(strconv.Itoa(size)+" upvote groups", func(t *testing.T) { + f := newUnreadCountFixture(t) + retentionSeenAt(t, f.db, f.recipient, f.sortAt.Add(-time.Second)) + for i := 0; i < size; i++ { + subject := f.post(t, posts.AdmissionStatusAccepted, false) + if i%2 == 0 { + setBridgedGroupTotals(t, f.db, "post", subject, 4, 0) + } else { + setBridgedGroupTotals(t, f.db, "post", subject, 5, 0) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), subject, "up", f.sortAt) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), subject, "up", f.sortAt.Add(time.Microsecond)) + } + f.listGroupAt(t, subject, subject, f.sortAt.Add(time.Duration(i+1)*time.Second)) + } + repository, counter := bridgedListCountingRepository(t, f.db) + page, err := repository.List(context.Background(), f.recipient, "", size) + require.NoError(t, err) + require.Equal(t, size, len(page.Notifications), "bridged-only groups must fill the page") + for _, row := range page.Notifications { + require.Equal(t, notifications.ReasonUpvote, row.Reason) + if row.SortAt.Sub(f.sortAt)/time.Second%2 == 1 { + require.Equal(t, 4, row.UpvoteCount, "bridged-only group %s", row.SubjectURI) + require.Empty(t, row.RecentUpvoterDIDs) + } else { + require.Equal(t, 7, row.UpvoteCount, "mixed group %s", row.SubjectURI) + require.Len(t, row.RecentUpvoterDIDs, 2) + } + } + require.Equal(t, 3, counter.total(), "one state, one page, exactly one aggregate statement") + statementTotals = append(statementTotals, counter.total()) + }) + } + if len(statementTotals) == 2 { + require.Equal(t, statementTotals[0], statementTotals[1], "50 and 8 rows must cost the same number of statements") + } +} + +// SubPlan and InitPlan children are deliberately included: both can contain +// the lookup for a bridged subject even when the outer scan uses other aliases. +func bridgedPlanAliasNodes(node map[string]any, alias string) []map[string]any { + var matches []map[string]any + if node["Alias"] == alias { + matches = append(matches, node) + } + children, _ := node["Plans"].([]any) + for _, child := range children { + if plan, ok := child.(map[string]any); ok { + matches = append(matches, bridgedPlanAliasNodes(plan, alias)...) + } + } + return matches +} + +func bridgedURIIndexes(t *testing.T, db *sql.DB, table string) map[string]bool { + t.Helper() + rows, err := db.QueryContext(context.Background(), `SELECT indexname FROM pg_indexes + WHERE schemaname = current_schema() AND tablename = $1 AND indexdef LIKE '%(uri)%'`, table) + require.NoError(t, err) + defer rows.Close() + indexes := map[string]bool{} + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + indexes[name] = true + } + require.NoError(t, rows.Err()) + require.NotEmpty(t, indexes, "%s must have a uri index", table) + return indexes +} + +func requireBridgedPointLookups(t *testing.T, plan map[string]any, raw []byte, indexes map[string]map[string]bool) { + t.Helper() + for alias, table := range map[string]string{"bridged_post": "posts", "bridged_comment": "comments"} { + nodes := bridgedPlanAliasNodes(plan, alias) + require.NotEmpty(t, nodes, "missing %s alias node in plan: %s", alias, raw) + for _, node := range nodes { + require.Contains(t, []string{"Index Scan", "Index Only Scan"}, node["Node Type"], "%s must use point lookup: %s", alias, raw) + name, _ := node["Index Name"].(string) + require.True(t, indexes[table][name], "%s must use %s.uri index, got %q: %s", alias, table, name, raw) + loops, _ := node["Actual Loops"].(float64) + require.Greater(t, loops, float64(0), "%s lookup must execute: %s", alias, raw) + } + } +} + +func TestNotificationRepository_BridgedStatementsProbeSubjectURIIndexes(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := f.sortAt + post := f.post(t, posts.AdmissionStatusAccepted, false) + comment := seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), base) + setBridgedGroupTotals(t, f.db, "post", post, 3, 0) + setBridgedGroupTotals(t, f.db, "comment", comment, 5, 0) + f.listGroupAt(t, post, post, base.Add(time.Second)) + f.listGroupAt(t, comment, f.root, base.Add(3*time.Second)) // newest visible row drives the NULL-seen_at InitPlan + pending := f.post(t, posts.AdmissionStatusPending, false) + f.listGroupAt(t, pending, pending, base.Add(2*time.Second)) + f.listNotificationAt(t, f.recipient, f.actor, "postReply", f.comment(t, f.root), pending, pending, base.Add(4*time.Second)) + hiddenPrefix := "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + "-hidden-" + insertUnreadCountRows(t, f.db, f.recipient, f.actor, hiddenPrefix, f.root, pending, base.Add(2*time.Second), 40, "all") + // Add unrelated subjects so scanning posts/comments instead of probing uri + // indexes is distinguishable after ANALYZE. + _, err := f.db.ExecContext(context.Background(), `INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + SELECT $1::text || number::text, 'bafybulkpost', number::text, $2, $3, 'unrelated', $4::timestamptz + FROM generate_series(1, 2000) number`, post+"-unrelated-", f.recipient, f.community, base) + require.NoError(t, err) + _, err = f.db.ExecContext(context.Background(), `INSERT INTO comments + (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + SELECT $1::text || number::text, 'bafybulkcomment', number::text, $2, $3, 'bafybulkroot', + $3, 'bafybulkroot', 'unrelated', $4::timestamptz + FROM generate_series(1, 2000) number`, comment+"-unrelated-", f.actor, f.root, base) + require.NoError(t, err) + for _, table := range []string{"notifications", "notification_state", "posts", "comments"} { + _, err := f.db.ExecContext(context.Background(), "ANALYZE "+table) + require.NoError(t, err) + } + indexes := map[string]map[string]bool{ + "posts": bridgedURIIndexes(t, f.db, "posts"), "comments": bridgedURIIndexes(t, f.db, "comments"), + } + countSQL := buildCountUnreadNotificationsSQL(true) + pageSQL := buildListNotificationsSQL(true) + aggregateSQL := buildListNotificationUpvotesSQL(true) + + t.Run("count", func(t *testing.T) { + retentionSeenAt(t, f.db, f.recipient, base) // before both visible groups + var count int + require.NoError(t, f.db.QueryRowContext(context.Background(), countSQL, f.recipient).Scan(&count)) + require.Equal(t, 2, count, "only post and comment bridged groups are unread") + plan, raw := bridgedCountPlan(t, f.db, countSQL, f.recipient) + node := unreadCountPlanIndexNode(plan, "idx_notifications_recipient_sort") + require.NotNil(t, node, "recipient index: %s", raw) + condition, _ := node["Index Cond"].(string) + require.Contains(t, condition, "sort_at", "recipient index must bound seen_at: %s", raw) + requireBridgedPointLookups(t, plan, raw, indexes) + }) + t.Run("page with NULL seen_at", func(t *testing.T) { + _, err := f.db.ExecContext(context.Background(), `UPDATE notification_state SET seen_at = NULL WHERE did = $1`, f.recipient) + require.NoError(t, err) + rows, err := f.db.QueryContext(context.Background(), pageSQL, f.recipient, nil, nil, 11) + require.NoError(t, err) + readStates := map[string]bool{} + for rows.Next() { + var id int64 + var reason, record, actor, subject, root, rootState, rootCID, subjectState, subjectCID, recordState, recordCID sql.NullString + var created sql.NullTime + var sortAt time.Time + var isRead bool + require.NoError(t, rows.Scan(&id, &reason, &record, &actor, &subject, &root, &created, &sortAt, + &isRead, &rootState, &rootCID, &subjectState, &subjectCID, &recordState, &recordCID)) + readStates[subject.String] = isRead + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.Equal(t, map[string]bool{post: true, comment: false}, readStates, "newest visible comment is unread") + plan, raw := bridgedCountPlan(t, f.db, pageSQL, f.recipient, nil, nil, 11) + requireBridgedPointLookups(t, plan, raw, indexes) + }) + t.Run("aggregate", func(t *testing.T) { + rows, err := f.db.QueryContext(context.Background(), aggregateSQL, f.recipient, pq.Array([]string{post, comment})) + require.NoError(t, err) + totals := map[string]int{} + for rows.Next() { + var subject string + var total int + var voters pq.StringArray + require.NoError(t, rows.Scan(&subject, &total, &voters)) + require.Empty(t, voters, "bridged-only subjects have no native recent voters") + totals[subject] = total + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.Equal(t, map[string]int{post: 3, comment: 5}, totals) + plan, raw := bridgedCountPlan(t, f.db, aggregateSQL, f.recipient, pq.Array([]string{post, comment})) + requireBridgedPointLookups(t, plan, raw, indexes) + }) +} diff --git a/internal/db/postgres/notification_earlier_upvote_test.go b/internal/db/postgres/notification_earlier_upvote_test.go new file mode 100644 index 0000000..5f82051 --- /dev/null +++ b/internal/db/postgres/notification_earlier_upvote_test.go @@ -0,0 +1,73 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationLookups_EarlierUpvoteExists(t *testing.T) { + t.Parallel() + const ( + voter = "did:plc:earlierupvotevoter" + otherVoter = "did:plc:earlierupvoteother" + subject = "at://did:plc:earlierupvoteauthor/social.coves.community.postv2/subject" + otherSubject = "at://did:plc:earlierupvoteauthor/social.coves.community.postv2/other" + ) + for _, test := range []struct { + name string + ownDeleted bool + extraVoter string + extraSubject string + extraDirection string + extraDeleted bool + want bool + }{ + {name: "only_own_vote"}, + {name: "live_earlier_upvote", ownDeleted: true, extraVoter: voter, extraSubject: subject, extraDirection: "up", want: true}, + {name: "soft_deleted_earlier_upvote", extraVoter: voter, extraSubject: subject, extraDirection: "up", extraDeleted: true, want: true}, + {name: "soft_deleted_earlier_downvote_only", extraVoter: voter, extraSubject: subject, extraDirection: "down", extraDeleted: true}, + {name: "other_voter_upvote", extraVoter: otherVoter, extraSubject: subject, extraDirection: "up"}, + {name: "other_subject_upvote", extraVoter: voter, extraSubject: otherSubject, extraDirection: "up", extraDeleted: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + now := time.Now().UTC() + insertVote := func(voterDID, subjectURI, direction, key string, createdAt time.Time, deleted bool) string { + t.Helper() + uri := "at://" + voterDID + "/social.coves.feed.vote/" + key + var deletedAt *time.Time + if deleted { + deletedAt = &now + } + _, err := transaction.ExecContext(ctx, `INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, deleted_at) + VALUES ($1, 'bafyearlierupvote', $2, $3, $4, 'bafyearliersubject', $5, $6, $7)`, + uri, key, voterDID, subjectURI, direction, createdAt, deletedAt) + require.NoError(t, err) + return uri + } + // The unique_voter_subject_active index forbids two live rows for + // the same voter and subject; only the live-earlier case soft-deletes V. + voteURI := insertVote(voter, subject, "up", testkit.TID(), now, test.ownDeleted) + if test.extraVoter != "" { + insertVote(test.extraVoter, test.extraSubject, test.extraDirection, + testkit.TID(), now.Add(-time.Hour), test.extraDeleted) + } + found, err := NewNotificationRepository(db).LookupsTx(transaction).EarlierUpvoteExists(ctx, voter, subject, voteURI) + require.NoError(t, err) + require.Equal(t, test.want, found, "only another upvote by this voter on this subject counts, even if soft-deleted") + }) + } +} diff --git a/internal/db/postgres/notification_erasure_race_test.go b/internal/db/postgres/notification_erasure_race_test.go new file mode 100644 index 0000000..6a94200 --- /dev/null +++ b/internal/db/postgres/notification_erasure_race_test.go @@ -0,0 +1,235 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func notificationRaceTransaction(t *testing.T, db *sql.DB, ctx context.Context) (*sql.Tx, int) { + t.Helper() + connection, err := db.Conn(ctx) + require.NoError(t, err) + t.Cleanup(func() { + closeError := connection.Close() + require.True(t, closeError == nil || errors.Is(closeError, sql.ErrConnDone), "closing fixture connection: %v", closeError) + }) + transaction, err := connection.BeginTx(ctx, nil) + require.NoError(t, err) + t.Cleanup(func() { + rollbackError := transaction.Rollback() + assert.True(t, rollbackError == nil || errors.Is(rollbackError, sql.ErrTxDone), "rolling back fixture transaction: %v", rollbackError) + }) + var processID int + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&processID)) + return transaction, processID +} + +// notificationRaceTryErasureLockShared tries the shared erasure lock for did on a +// backend other than excludedProcessID and reports whether it was granted. A held +// sql.Conn is never handed out twice, so when the first connection lands on the +// excluded backend the second one cannot. Both connections go back to the pool +// before returning; the xact lock taken in autocommit ends with its statement. +func notificationRaceTryErasureLockShared(t *testing.T, db *sql.DB, ctx context.Context, excludedProcessID int, did string) bool { + t.Helper() + closeConnection := func(connection *sql.Conn) { + closeError := connection.Close() + assert.True(t, closeError == nil || errors.Is(closeError, sql.ErrConnDone), "closing probe connection: %v", closeError) + } + backendProcessID := func(connection *sql.Conn) int { + var processID int + require.NoError(t, connection.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&processID)) + return processID + } + probeConnection, err := db.Conn(ctx) + require.NoError(t, err) + defer closeConnection(probeConnection) + if backendProcessID(probeConnection) == excludedProcessID { + secondConnection, err := db.Conn(ctx) + require.NoError(t, err) + defer closeConnection(secondConnection) + require.NotEqual(t, excludedProcessID, backendProcessID(secondConnection), "the probe connection must not share Delete's backend") + probeConnection = secondConnection + } + var acquired bool + require.NoError(t, probeConnection.QueryRowContext(ctx, + "SELECT pg_try_advisory_xact_lock_shared("+ErasureLockKeySQL+")", did).Scan(&acquired)) + return acquired +} + +func notificationRaceBlockedDelete(t *testing.T, db *sql.DB, ctx context.Context, blockingProcessID int, queryFragment string) { + t.Helper() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + var processID int + err := db.QueryRowContext(ctx, ` + SELECT pid FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND $1 = ANY(pg_blocking_pids(pid)) + AND wait_event_type = 'Lock' + AND query ILIKE $2 + LIMIT 1`, blockingProcessID, "%"+queryFragment+"%").Scan(&processID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("user deletion blocked by the fixture transaction at %s", queryFragment)) +} + +func notificationRaceRequirePending(t *testing.T, results <-chan error) { + t.Helper() + select { + case err := <-results: + t.Fatalf("Delete returned before the fixture transaction committed: %v", err) + default: + } +} + +func notificationRaceDeadlock(err error) bool { + var databaseError *pq.Error + return errors.As(err, &databaseError) && databaseError.Code == "40P01" +} + +func TestUserRepo_Delete_NotificationActorWaitsForErasureAdvisoryLock(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + actorDID := "did:plc:notificationraceactor" + testkit.UniqueID(t) + createTestUser(t, db, "notificationraceactor"+testkit.UniqueID(t)+".test", actorDID) + transaction, holderProcessID := notificationRaceTransaction(t, db, ctx) + _, err := transaction.ExecContext(ctx, "SELECT pg_advisory_xact_lock_shared("+ErasureLockKeySQL+")", actorDID) + require.NoError(t, err) + + results := make(chan error, 1) + go func() { results <- NewUserRepository(db).Delete(ctx, actorDID) }() + + var waiterProcessID int + var waiterMode, holderMode string + testkit.WaitFor(t, 2*time.Second, func() (bool, error) { + err := db.QueryRowContext(ctx, ` + SELECT waiter.pid, waiter.mode, holder.mode + FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database + AND holder.classid = waiter.classid + AND holder.objid = waiter.objid + AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid <> holder.pid + AND holder.locktype = 'advisory' + AND holder.database = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND waiter.database = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND holder.granted AND NOT waiter.granted + LIMIT 1`, holderProcessID).Scan(&waiterProcessID, &waiterMode, &holderMode) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("actor deletion waiting for its erasure advisory lock before the marker")) + require.Equal(t, "ExclusiveLock", waiterMode) + require.Equal(t, "ShareLock", holderMode) + var lockCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM pg_locks + WHERE pid = $1 AND database = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND relation = 'deleted_accounts'::regclass`, waiterProcessID).Scan(&lockCount)) + require.Zero(t, lockCount, "the erasure marker table must not yet be locked by Delete") + notificationRaceRequirePending(t, results) + + require.NoError(t, transaction.Commit()) + require.NoError(t, <-results) + var markerCount int + require.NoError(t, db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deleted_accounts WHERE did = $1`, actorDID).Scan(&markerCount)) + require.Equal(t, 1, markerCount, "the erasure marker must be written once the advisory lock is released") + require.True(t, notificationRaceTryErasureLockShared(t, db, ctx, waiterProcessID, actorDID), "the erasure lock must be transaction-scoped; a session-level lock would leak on the pooled connection") + var userCount int + require.NoError(t, db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE did = $1`, actorDID).Scan(&userCount)) + require.Zero(t, userCount) +} + +func TestUserRepo_Delete_NotificationRecipientWaitsForConcurrentInsert(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + recipientDID := "did:plc:notificationracerecipient" + testkit.UniqueID(t) + createTestUser(t, db, "notificationracerecipient"+testkit.UniqueID(t)+".test", recipientDID) + transaction, transactionProcessID := notificationRaceTransaction(t, db, ctx) + rootPostURI := "at://" + recipientDID + "/social.coves.community.post/root" + _, err := transaction.ExecContext(ctx, ` + INSERT INTO notifications (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri) + VALUES ($1, 'postReply', $2, 'bafyreply', $3, $4, $4)`, + recipientDID, "at://did:plc:notificationother/social.coves.community.comment/reply", + "did:plc:notificationother", rootPostURI) + require.NoError(t, err) + + results := make(chan error, 1) + go func() { results <- NewUserRepository(db).Delete(ctx, recipientDID) }() + notificationRaceBlockedDelete(t, db, ctx, transactionProcessID, "DELETE FROM users") + notificationRaceRequirePending(t, results) + + require.NoError(t, transaction.Commit()) + require.NoError(t, <-results) + var remaining int + require.NoError(t, db.QueryRowContext(ctx, `SELECT COUNT(*) FROM notifications WHERE recipient_did = $1`, recipientDID).Scan(&remaining)) + require.Zero(t, remaining, "the committed notification must be erased") + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM notifications n + WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.did = n.recipient_did)`).Scan(&remaining)) + require.Zero(t, remaining, "no notification may outlive its recipient") +} + +func TestUserRepo_Delete_NotificationGroupUpsertAfterContentLock(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + suffix := testkit.UniqueID(t) + recipientDID := "did:plc:notificationraceowner" + suffix + communityDID := "did:plc:notificationracecommunity" + suffix + createTestUser(t, db, "notificationraceowner"+suffix+".test", recipientDID) + createTestCommunity(t, db, communityDID, "c.notificationrace"+suffix, recipientDID) + postURI := "at://" + recipientDID + "/social.coves.community.post/owned" + _, err := db.ExecContext(ctx, ` + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at, upvote_count) + VALUES ($1, 'bafyracepost', 'owned', $2, $3, 'Race post', NOW(), 0)`, postURI, recipientDID, communityDID) + require.NoError(t, err) + _, err = db.ExecContext(ctx, ` + INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri) + VALUES ($1, 'upvote', $2, $2)`, recipientDID, postURI) + require.NoError(t, err) + + transaction, transactionProcessID := notificationRaceTransaction(t, db, ctx) + _, err = transaction.ExecContext(ctx, `UPDATE posts SET upvote_count = upvote_count + 1 WHERE uri = $1`, postURI) + require.NoError(t, err) + results := make(chan error, 1) + go func() { results <- NewUserRepository(db).Delete(ctx, recipientDID) }() + notificationRaceBlockedDelete(t, db, ctx, transactionProcessID, "DELETE FROM posts") + notificationRaceRequirePending(t, results) + + _, upsertError := transaction.ExecContext(ctx, ` + INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $2, NOW()) + ON CONFLICT (recipient_did, subject_uri) WHERE reason = 'upvote' + DO UPDATE SET sort_at = GREATEST(notifications.sort_at, EXCLUDED.sort_at)`, recipientDID, postURI) + require.NoErrorf(t, upsertError, "upsert failed (SQLSTATE 40P01 deadlock: %t)", notificationRaceDeadlock(upsertError)) + commitError := transaction.Commit() + require.NoErrorf(t, commitError, "commit failed (SQLSTATE 40P01 deadlock: %t)", notificationRaceDeadlock(commitError)) + deleteError := <-results + require.NoErrorf(t, deleteError, "Delete failed (SQLSTATE 40P01 deadlock: %t)", notificationRaceDeadlock(deleteError)) + var groupCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + recipientDID, postURI).Scan(&groupCount)) + require.Zero(t, groupCount, "recipient erasure must remove the upvote group after the consumer commits") +} diff --git a/internal/db/postgres/notification_erasure_test.go b/internal/db/postgres/notification_erasure_test.go new file mode 100644 index 0000000..25a4740 --- /dev/null +++ b/internal/db/postgres/notification_erasure_test.go @@ -0,0 +1,253 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type erasureNotification struct { + recipientDID string + reason string + recordURI string + actorDID string + subjectURI string +} + +func notificationRowsForErasure(t *testing.T, db *sql.DB) []erasureNotification { + t.Helper() + rows, err := db.Query(` + SELECT recipient_did, reason, COALESCE(record_uri, ''), COALESCE(actor_did, ''), COALESCE(subject_uri, '') + FROM notifications ORDER BY recipient_did, reason, record_uri, subject_uri`) + require.NoError(t, err) + defer rows.Close() + + var notifications []erasureNotification + for rows.Next() { + var notification erasureNotification + require.NoError(t, rows.Scan(¬ification.recipientDID, ¬ification.reason, + ¬ification.recordURI, ¬ification.actorDID, ¬ification.subjectURI)) + notifications = append(notifications, notification) + } + require.NoError(t, rows.Err()) + return notifications +} + +func TestUserRepo_Delete_ErasesNotificationActorAndRecipient(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx := context.Background() + repo := NewUserRepository(db) + suffix := testkit.UniqueID(t) + actorA := "did:plc:notifyactor" + suffix + recipientB := "did:plc:notifyrecipientb" + suffix + recipientC := "did:plc:notifyrecipientc" + suffix + actorD := "did:plc:notifyactord" + suffix + for _, user := range []struct{ handle, did string }{ + {"notifyactor" + suffix + ".test", actorA}, + {"notifyrecipientb" + suffix + ".test", recipientB}, + {"notifyrecipientc" + suffix + ".test", recipientC}, + {"notifyactord" + suffix + ".test", actorD}, + } { + createTestUser(t, db, user.handle, user.did) + } + + postB := "at://" + recipientB + "/social.coves.community.post/owned" + postC := "at://" + recipientC + "/social.coves.community.post/owned" + replyB := "at://" + actorA + "/social.coves.community.comment/replyb" + mentionB := "at://" + actorD + "/social.coves.community.comment/mentionb" + replyCFromA := "at://" + actorA + "/social.coves.community.comment/replyc" + mentionCFromA := "at://" + actorA + "/social.coves.community.comment/mentionc" + replyCFromD := "at://" + actorD + "/social.coves.community.comment/replyc" + + for _, notification := range []struct { + recipientDID, reason, recordURI, actorDID, subjectURI, rootPostURI string + }{ + {recipientB, "postReply", replyB, actorA, postB, postB}, + {recipientB, "mention", mentionB, actorD, "", postB}, + {recipientC, "postReply", replyCFromA, actorA, postC, postC}, + {recipientC, "mention", mentionCFromA, actorA, "", postC}, + {recipientC, "postReply", replyCFromD, actorD, postC, postC}, + } { + _, err := db.Exec(`INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri) + VALUES ($1, $2, $3, 'bafytestnotification', $4, NULLIF($5, ''), $6)`, + notification.recipientDID, notification.reason, notification.recordURI, + notification.actorDID, notification.subjectURI, notification.rootPostURI) + require.NoError(t, err) + } + for _, group := range []struct{ recipientDID, postURI string }{ + {recipientB, postB}, {recipientC, postC}, + } { + _, err := db.Exec(`INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri) + VALUES ($1, 'upvote', $2, $2)`, group.recipientDID, group.postURI) + require.NoError(t, err) + } + for _, did := range []string{recipientB, recipientC} { + _, err := db.Exec(`INSERT INTO notification_state (did, seen_at) VALUES ($1, NOW())`, did) + require.NoError(t, err) + } + stateRowCount := func(did string) int { + t.Helper() + var stateCount int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM notification_state WHERE did = $1`, did).Scan(&stateCount)) + return stateCount + } + + expectedBefore := []erasureNotification{ + {recipientB, "mention", mentionB, actorD, ""}, + {recipientB, "postReply", replyB, actorA, postB}, + {recipientB, "upvote", "", "", postB}, + {recipientC, "mention", mentionCFromA, actorA, ""}, + {recipientC, "postReply", replyCFromA, actorA, postC}, + {recipientC, "postReply", replyCFromD, actorD, postC}, + {recipientC, "upvote", "", "", postC}, + } + require.ElementsMatch(t, expectedBefore, notificationRowsForErasure(t, db)) + + require.NoError(t, repo.Delete(ctx, actorA)) + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE actor_did = $1`, actorA).Scan(&count)) + require.Zero(t, count, "erased actor must leave no notifications for other recipients") + expectedAfterActor := []erasureNotification{ + {recipientB, "mention", mentionB, actorD, ""}, + {recipientB, "upvote", "", "", postB}, + {recipientC, "postReply", replyCFromD, actorD, postC}, + {recipientC, "upvote", "", "", postC}, + } + require.ElementsMatch(t, expectedAfterActor, notificationRowsForErasure(t, db)) + require.Equal(t, 1, stateRowCount(recipientB), "erasing actor A must not delete B's notification_state row") + require.Equal(t, 1, stateRowCount(recipientC), "erasing actor A must not delete C's notification_state row") + + require.NoError(t, repo.Delete(ctx, recipientB)) + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE recipient_did = $1`, recipientB).Scan(&count)) + require.Zero(t, count, "erased recipient must leave no inbox rows") + require.Zero(t, stateRowCount(recipientB), "erased recipient must leave no state") + require.Equal(t, 1, stateRowCount(recipientC), "erasing recipient B must not delete C's notification_state row") + require.ElementsMatch(t, expectedAfterActor[2:], notificationRowsForErasure(t, db), "C's notifications must survive both erasures") +} + +func TestUserRepo_Delete_VoterErasurePreservesRecipientUpvoteGroup(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx := context.Background() + suffix := testkit.UniqueID(t) + voterDID := "did:plc:notifyvoter" + suffix + recipientDID := "did:plc:notifyowner" + suffix + createTestUser(t, db, "notifyvoter"+suffix+".test", voterDID) + createTestUser(t, db, "notifyowner"+suffix+".test", recipientDID) + postURI := "at://" + recipientDID + "/social.coves.community.post/owned" + voteURI := "at://" + voterDID + "/social.coves.feed.vote/onowned" + _, err := db.Exec(`INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at) + VALUES ($1, 'bafytestvote', 'onowned', $2, $3, 'bafytestpost', 'up', NOW())`, + voteURI, voterDID, postURI) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri) + VALUES ($1, 'upvote', $2, $2)`, recipientDID, postURI) + require.NoError(t, err) + group := []erasureNotification{{recipientDID, "upvote", "", "", postURI}} + require.ElementsMatch(t, group, notificationRowsForErasure(t, db)) + + require.NoError(t, NewUserRepository(db).Delete(ctx, voterDID)) + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM votes WHERE uri = $1`, voteURI).Scan(&count)) + require.Zero(t, count, "erased voter's live vote must be deleted") + require.ElementsMatch(t, group, notificationRowsForErasure(t, db), "recipient's upvote group must not be deleted with the voter") +} + +type publicWithdrawalMarker struct { + postURI string + kind string +} + +func publicWithdrawalMarkers(t *testing.T, db *sql.DB) []publicWithdrawalMarker { + t.Helper() + rows, err := db.Query(`SELECT post_uri, kind FROM notification_public_post_withdrawals`) + require.NoError(t, err) + defer rows.Close() + + var markers []publicWithdrawalMarker + for rows.Next() { + var marker publicWithdrawalMarker + require.NoError(t, rows.Scan(&marker.postURI, &marker.kind)) + markers = append(markers, marker) + } + require.NoError(t, rows.Err()) + return markers +} + +func TestUserRepo_Delete_ErasesUsersPublicWithdrawalMarkersOfBothKinds(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + failDeletion bool + }{ + {"successful erasure", false}, + {"failed erasure rolls back marker deletion", true}, + } { + t.Run(test.name, func(t *testing.T) { + db := testkit.DB(t) + ctx := context.Background() + suffix := testkit.UniqueID(t) + authorA := "did:plc:withdrawala" + suffix + authorB := "did:plc:withdrawalb" + suffix + createTestUser(t, db, "withdrawala"+suffix+".test", authorA) + createTestUser(t, db, "withdrawalb"+suffix+".test", authorB) + + postA1 := "at://" + authorA + "/social.coves.community.postv2/first" + postA2 := "at://" + authorA + "/social.coves.community.postv2/second" + postB := "at://" + authorB + "/social.coves.community.postv2/other" + before := []publicWithdrawalMarker{ + {postA1, "authorDelete"}, + {postA2, "communityWithdrawal"}, + {postB, "authorDelete"}, + {postB, "communityWithdrawal"}, + } + for _, marker := range []struct { + publicWithdrawalMarker + communityRev any + }{ + {before[0], nil}, + {before[1], "rev-a"}, + {before[2], nil}, + {before[3], "rev-b"}, + } { + _, err := db.ExecContext(ctx, `INSERT INTO notification_public_post_withdrawals + (post_uri, kind, community_rev) VALUES ($1, $2, $3)`, + marker.postURI, marker.kind, marker.communityRev) + require.NoError(t, err) + } + require.ElementsMatch(t, before, publicWithdrawalMarkers(t, db)) + + if test.failDeletion { + _, err := db.ExecContext(ctx, `CREATE FUNCTION reject_public_withdrawal_erasure() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced users deletion failure'; END $$`) + require.NoError(t, err) + _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE TRIGGER reject_public_withdrawal_erasure + BEFORE DELETE ON users FOR EACH ROW WHEN (OLD.did = '%s') + EXECUTE FUNCTION reject_public_withdrawal_erasure()`, authorA)) + require.NoError(t, err) + require.Error(t, NewUserRepository(db).Delete(ctx, authorA)) + require.ElementsMatch(t, before, publicWithdrawalMarkers(t, db), + "failed user deletion must roll back withdrawal marker deletions") + var users int + require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE did = $1`, authorA).Scan(&users)) + require.Equal(t, 1, users) + return + } + + require.NoError(t, NewUserRepository(db).Delete(ctx, authorA)) + require.ElementsMatch(t, []publicWithdrawalMarker{ + {postB, "authorDelete"}, + {postB, "communityWithdrawal"}, + }, publicWithdrawalMarkers(t, db), "only the erased author's markers must be removed") + }) + } +} diff --git a/internal/db/postgres/notification_existing_mentions_test.go b/internal/db/postgres/notification_existing_mentions_test.go new file mode 100644 index 0000000..0f7e3a6 --- /dev/null +++ b/internal/db/postgres/notification_existing_mentions_test.go @@ -0,0 +1,73 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationRepository_ExistingMentionRecipientsReadsOnlyRecordMentionsInTransaction(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + actorDID := "did:plc:" + uniqueID + "actor" + recordURI := "at://" + actorDID + "/social.coves.community.comment/first" + otherRecordURI := "at://" + actorDID + "/social.coves.community.comment/second" + rootPostURI := "at://" + actorDID + "/social.coves.community.postv2/root" + recipients := []string{ + "did:plc:" + uniqueID + "first", + "did:plc:" + uniqueID + "second", + "did:plc:" + uniqueID + "reply", + "did:plc:" + uniqueID + "other", + } + for index, recipientDID := range recipients { + createTestUser(t, db, uniqueID+string(rune('a'+index))+".test", recipientDID) + } + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + makeIntent := func(recipientDID string, reason notifications.Reason, uri string) notifications.Intent { + intent := notifications.Intent{ + RecipientDID: recipientDID, Reason: reason, ActorDID: actorDID, + RecordURI: uri, RecordCID: "bafyreiexistingmentions", RootPostURI: rootPostURI, + RecordCreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + } + if reason == notifications.ReasonCommentReply { + intent.SubjectURI = "at://" + recipients[2] + "/social.coves.community.comment/parent" + } + return intent + } + repository := NewNotificationRepository(db) + require.NoError(t, repository.ApplyTx(ctx, transaction, []notifications.Intent{ + makeIntent(recipients[0], notifications.ReasonMention, recordURI), + makeIntent(recipients[1], notifications.ReasonMention, recordURI), + makeIntent(recipients[2], notifications.ReasonCommentReply, recordURI), + makeIntent(recipients[3], notifications.ReasonMention, otherRecordURI), + }), "seed uncommitted notifications in the lookup's transaction") + var seeded int + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT count(*) FROM notifications`).Scan(&seeded)) + require.Equal(t, 4, seeded, "fixture: all four rows must exist in the writing transaction") + + lookups := repository.LookupsTx(transaction) + got, err := lookups.ExistingMentionRecipients(ctx, recordURI) + require.NoError(t, err) + require.Len(t, got, 2, "only mention recipients for this record consume its budget") + for index, expectedDID := range recipients[:2] { + found := false + for _, actualDID := range got { + found = found || actualDID == expectedDID + } + require.True(t, found, "mention recipient %d must be returned", index) + } + got, err = lookups.ExistingMentionRecipients(ctx, "at://"+actorDID+"/social.coves.community.comment/missing") + require.NoError(t, err) + require.Empty(t, got, "a record without mentions has no existing recipients") +} diff --git a/internal/db/postgres/notification_gate_test.go b/internal/db/postgres/notification_gate_test.go new file mode 100644 index 0000000..3d809f7 --- /dev/null +++ b/internal/db/postgres/notification_gate_test.go @@ -0,0 +1,351 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationRepository_ErasureGateTx_CommittedMarker(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx := context.Background() + suffix := testkit.UniqueID(t) + activeDID := "did:plc:notificationactive" + suffix + erasedDID := "did:plc:notificationerased" + suffix + _, err := db.ExecContext(ctx, `INSERT INTO deleted_accounts (did) VALUES ($1)`, erasedDID) + require.NoError(t, err) + + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + repository := NewNotificationRepository(db) + erased, err := repository.ErasureGateTx(ctx, transaction, activeDID) + require.NoError(t, err) + require.False(t, erased, "an actor without a deleted_accounts marker must pass the gate") + erased, err = repository.ErasureGateTx(ctx, transaction, erasedDID) + require.NoError(t, err) + require.True(t, erased, "a committed deleted_accounts marker must stop the actor") +} + +func TestNotificationRepository_ErasureGateTx_ChecksMarkerAfterWaitingForLock(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + actorDID := "did:plc:notificationgateactor" + testkit.UniqueID(t) + fixtureTransaction, holderProcessID := notificationRaceTransaction(t, db, ctx) + _, err := fixtureTransaction.ExecContext(ctx, "SELECT pg_advisory_xact_lock("+ErasureLockKeySQL+")", actorDID) + require.NoError(t, err) + _, err = fixtureTransaction.ExecContext(ctx, `INSERT INTO deleted_accounts (did) VALUES ($1)`, actorDID) + require.NoError(t, err) + + type gateStart struct { + processID int + err error + } + type gateResult struct { + erased bool + err error + } + started := make(chan gateStart, 1) + results := make(chan gateResult, 1) + go func() { + transaction, beginError := db.BeginTx(ctx, nil) + if beginError != nil { + started <- gateStart{err: beginError} + return + } + defer transaction.Rollback() + var processID int + if processError := transaction.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&processID); processError != nil { + started <- gateStart{err: processError} + return + } + started <- gateStart{processID: processID} + erased, gateError := NewNotificationRepository(db).ErasureGateTx(ctx, transaction, actorDID) + results <- gateResult{erased: erased, err: gateError} + }() + + var gateBackend gateStart + select { + case gateBackend = <-started: + case <-ctx.Done(): + require.FailNow(t, "gate transaction did not start", ctx.Err().Error()) + } + require.NoError(t, gateBackend.err) + require.Positive(t, gateBackend.processID) + + returnedBeforeCommit := false + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + select { + case <-results: + returnedBeforeCommit = true + return true, nil + default: + } + var waiting bool + err := fixtureTransaction.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database + AND holder.classid = waiter.classid + AND holder.objid = waiter.objid + AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid = $2 + AND holder.locktype = 'advisory' + AND holder.granted AND holder.mode = 'ExclusiveLock' + AND NOT waiter.granted AND waiter.mode = 'ShareLock' + )`, holderProcessID, gateBackend.processID).Scan(&waiting) + return waiting, err + }, testkit.WithDescription("ErasureGateTx waiting on the actor's exclusive erasure lock")) + require.False(t, returnedBeforeCommit, "ErasureGateTx returned before acquiring the actor's shared erasure lock") + select { + case result := <-results: + require.FailNowf(t, "ErasureGateTx returned before erasure committed", "result: erased=%t, error=%v", result.erased, result.err) + default: + } + testkit.Holds(t, 200*time.Millisecond, func() (bool, error) { + select { + case <-results: + return false, nil + default: + return true, nil + } + }, testkit.WithDescription("ErasureGateTx remains blocked while erasure is uncommitted")) + + require.NoError(t, fixtureTransaction.Commit()) + select { + case result := <-results: + require.NoError(t, result.err) + require.True(t, result.erased, "marker must be checked in a separate statement after the erasure lock is released") + case <-ctx.Done(): + require.FailNow(t, "ErasureGateTx did not return after erasure committed", ctx.Err().Error()) + } +} + +func TestNotificationRepository_ErasureGateTx_RejectsRepeatableRead(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx := context.Background() + transaction, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + require.NoError(t, err) + defer transaction.Rollback() + _, err = NewNotificationRepository(db).ErasureGateTx(ctx, transaction, + "did:plc:notificationrepeatable"+testkit.UniqueID(t)) + require.ErrorIs(t, err, ErrErasureGateRequiresReadCommitted, + "a repeatable-read snapshot cannot observe a marker committed while waiting for the erasure lock") +} + +func TestNotificationRepository_ErasureGateTx_RejectsRepeatableReadBeforeWaitingForLock(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + // Registered before the fixture so its rollback cleanup runs while ctx is + // live. A deferred cancel fires first, and lib/pq then marks the still-open + // fixture connection bad, so that rollback would race to driver.ErrBadConn. + t.Cleanup(cancel) + actorDID := "did:plc:notificationrepeatablewait" + testkit.UniqueID(t) + fixtureTransaction, holderProcessID := notificationRaceTransaction(t, db, ctx) + _, err := fixtureTransaction.ExecContext(ctx, "SELECT pg_advisory_xact_lock("+ErasureLockKeySQL+")", actorDID) + require.NoError(t, err) + + type gateStart struct { + processID int + err error + } + started := make(chan gateStart, 1) + results := make(chan error, 1) + go func() { + transaction, beginError := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + if beginError != nil { + started <- gateStart{err: beginError} + return + } + defer transaction.Rollback() + var processID int + if processError := transaction.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&processID); processError != nil { + started <- gateStart{err: processError} + return + } + started <- gateStart{processID: processID} + _, gateError := NewNotificationRepository(db).ErasureGateTx(ctx, transaction, actorDID) + results <- gateError + }() + + var gateBackend gateStart + select { + case gateBackend = <-started: + case <-ctx.Done(): + require.FailNow(t, "gate transaction did not start", ctx.Err().Error()) + } + require.NoError(t, gateBackend.err) + require.Positive(t, gateBackend.processID) + + // Ordering, not a deadline: the gate either returns or is seen queued behind + // the fixture's erasure lock. The bound only stops a hang. + var gateError error + waitedOnLock := false + testkit.WaitFor(t, 10*time.Second, func() (bool, error) { + select { + case gateError = <-results: + return true, nil + default: + } + err := fixtureTransaction.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_locks waiter + JOIN pg_locks holder ON holder.locktype = waiter.locktype + AND holder.database = waiter.database + AND holder.classid = waiter.classid + AND holder.objid = waiter.objid + AND holder.objsubid = waiter.objsubid + WHERE holder.pid = $1 AND waiter.pid = $2 + AND holder.locktype = 'advisory' + AND holder.granted AND NOT waiter.granted + )`, holderProcessID, gateBackend.processID).Scan(&waitedOnLock) + return waitedOnLock, err + }, testkit.WithDescription("ErasureGateTx to return, or to queue behind the held erasure lock")) + require.False(t, waitedOnLock, + "ErasureGateTx waited on the held erasure lock; a repeatable-read transaction must be rejected before it takes the lock") + require.ErrorIs(t, gateError, ErrErasureGateRequiresReadCommitted, + "a repeatable-read transaction must be rejected before it waits on the held erasure lock") + require.NoError(t, fixtureTransaction.Rollback(), "the fixture must still hold the erasure lock when the gate returns") +} + +func TestNotificationRepository_ApplyTx_SkipsRecipientErasedDuringInsert(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + suffix := testkit.UniqueID(t) + erasedRecipientDID := "did:plc:notificationrecipientb" + suffix + remainingRecipientDID := "did:plc:notificationrecipientd" + suffix + actorDID := "did:plc:notificationreplyactor" + suffix + createTestUser(t, db, "notificationrecipientb"+suffix+".test", erasedRecipientDID) + createTestUser(t, db, "notificationrecipientd"+suffix+".test", remainingRecipientDID) + + fixtureTransaction, holderProcessID := notificationRaceTransaction(t, db, ctx) + deletion, err := fixtureTransaction.ExecContext(ctx, `DELETE FROM users WHERE did = $1`, erasedRecipientDID) + require.NoError(t, err) + deletedRows, err := deletion.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, deletedRows) + + rootPostURI := "at://" + remainingRecipientDID + "/social.coves.community.postv2/root" + intents := []notifications.Intent{ + { + Reason: notifications.ReasonPostReply, RecipientDID: erasedRecipientDID, ActorDID: actorDID, + RecordURI: "at://" + actorDID + "/social.coves.community.comment/replyb", + RecordCID: "bafyreplyb", SubjectURI: "at://" + erasedRecipientDID + "/social.coves.community.postv2/parent", + RootPostURI: "at://" + erasedRecipientDID + "/social.coves.community.postv2/parent", RecordCreatedAt: time.Now().UTC(), + }, + { + Reason: notifications.ReasonPostReply, RecipientDID: remainingRecipientDID, ActorDID: actorDID, + RecordURI: "at://" + actorDID + "/social.coves.community.comment/replyd", + RecordCID: "bafyreplyd", SubjectURI: rootPostURI, + RootPostURI: rootPostURI, RecordCreatedAt: time.Now().UTC(), + }, + } + type insertStart struct { + processID int + err error + } + type insertResult struct { + applyError error + commitError error + } + started := make(chan insertStart, 1) + results := make(chan insertResult, 1) + go func() { + transaction, beginError := db.BeginTx(ctx, nil) + if beginError != nil { + started <- insertStart{err: beginError} + return + } + defer transaction.Rollback() + var processID int + if processError := transaction.QueryRowContext(ctx, `SELECT pg_backend_pid()`).Scan(&processID); processError != nil { + started <- insertStart{err: processError} + return + } + started <- insertStart{processID: processID} + applyError := NewNotificationRepository(db).ApplyTx(ctx, transaction, intents) + if applyError != nil { + results <- insertResult{applyError: applyError} + return + } + results <- insertResult{commitError: transaction.Commit()} + }() + + var insertBackend insertStart + select { + case insertBackend = <-started: + case <-ctx.Done(): + require.FailNow(t, "notification insertion transaction did not start", ctx.Err().Error()) + } + require.NoError(t, insertBackend.err) + require.Positive(t, insertBackend.processID) + + returnedBeforeCommit := false + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + select { + case <-results: + returnedBeforeCommit = true + return true, nil + default: + } + // The fixture transaction owns the probe connection; a third connection + // is not available when the insert and delete each hold one. + _, err := fixtureTransaction.ExecContext(ctx, `SELECT pg_stat_clear_snapshot()`) + if err != nil { + return false, err + } + var waiting bool + err = fixtureTransaction.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE pid = $2 AND wait_event_type = 'Lock' + AND query ILIKE '%INSERT INTO notifications%' + AND $1 = ANY(pg_blocking_pids(pid)) + )`, holderProcessID, insertBackend.processID).Scan(&waiting) + return waiting, err + }, testkit.WithDescription("ApplyTx insert blocked by the recipient's uncommitted deletion")) + require.False(t, returnedBeforeCommit, "ApplyTx returned before the recipient deletion committed") + select { + case result := <-results: + require.FailNowf(t, "ApplyTx returned before the deletion committed", "apply error: %v; commit error: %v", result.applyError, result.commitError) + default: + } + testkit.Holds(t, 200*time.Millisecond, func() (bool, error) { + select { + case <-results: + return false, nil + default: + return true, nil + } + }, testkit.WithDescription("ApplyTx remains blocked while recipient deletion is uncommitted")) + + require.NoError(t, fixtureTransaction.Commit()) + select { + case result := <-results: + require.NoError(t, result.applyError, "recipient FK failure after erasure must skip only that intent") + require.NoError(t, result.commitError, "the enclosing transaction must remain usable after the recipient FK failure") + case <-ctx.Done(): + require.FailNow(t, "ApplyTx did not return after recipient deletion committed", ctx.Err().Error()) + } + var erasedCount, remainingCount int + require.NoError(t, db.QueryRowContext(ctx, `SELECT COUNT(*) FROM notifications WHERE recipient_did = $1`, erasedRecipientDID).Scan(&erasedCount)) + require.Zero(t, erasedCount, "the erased recipient must have no notification") + require.NoError(t, db.QueryRowContext(ctx, `SELECT COUNT(*) FROM notifications WHERE recipient_did = $1`, remainingRecipientDID).Scan(&remainingCount)) + require.Equal(t, 1, remainingCount, "the later recipient must still receive the reply") +} diff --git a/internal/db/postgres/notification_list.go b/internal/db/postgres/notification_list.go new file mode 100644 index 0000000..ab5876f --- /dev/null +++ b/internal/db/postgres/notification_list.go @@ -0,0 +1,202 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "Coves/internal/core/notifications" + + "github.com/lib/pq" +) + +const maximumNotificationListLimit = 100 + +// listNotificationsSQL has four parameters: recipient DID ($1), optional cursor +// sort time ($2) and ID ($3), and probe-inclusive row limit ($4). +// The combined CASE prevents the planner from multiplying selectivity guesses +// for each dependent reference state and choosing an unordered index plus Sort. +// With NULL seen_at, only the recipient's newest visible row of any reason is unread. +// Gate-off statement for existing plan tests; runtime uses r.listSQL. +var listNotificationsSQL = buildListNotificationsSQL(false) + +// buildListNotificationUpvotesSQL builds one statement per page: recipient ($1), +// listed upvote subject URIs ($2). +func buildListNotificationUpvotesSQL(bridgedTotals bool) string { + return `SELECT s.subject_uri, aggregates.vote_count, aggregates.recent_voters + FROM unnest($2::text[]) AS s(subject_uri) + CROSS JOIN LATERAL ( + SELECT COUNT(*) + ` + bridgedUpvoteTotalSQL("s.subject_uri", bridgedTotals) + ` AS vote_count, + COALESCE(array_agg(ranked.voter_did ORDER BY ranked.indexed_at DESC, ranked.id DESC) + FILTER (WHERE ranked.position <= 3), ARRAY[]::text[]) AS recent_voters + FROM ( + SELECT v.voter_did, v.indexed_at, v.id, + ROW_NUMBER() OVER (ORDER BY v.indexed_at DESC, v.id DESC) AS position + FROM votes v WHERE ` + qualifyingUpvoteSQL("v", "s.subject_uri", "$1") + ` + ) ranked + ) aggregates` +} + +func buildListNotificationsSQL(bridgedTotals bool) string { + visibility := notificationVisibility(bridgedTotals) + return `SELECT n.id, n.reason, n.record_uri, n.actor_did, n.subject_uri, + n.root_post_uri, n.record_created_at, n.sort_at, + CASE WHEN state.seen_at IS NOT NULL THEN n.sort_at <= state.seen_at + ELSE n.id <> ( + SELECT n.id FROM notifications n` + visibility.joins + ` + WHERE n.recipient_did = $1::text AND ` + visibility.visible + ` + ORDER BY n.sort_at DESC, n.id DESC LIMIT 1 + ) END AS is_read, + ` + visibility.rootPostState + ` AS root_state, root_post.cid, + COALESCE(` + visibility.subjectPostState + `, ` + visibility.subjectCommentState + `) AS subject_state, + COALESCE(subject_post.cid, subject_comment.cid) AS subject_cid, + COALESCE(` + visibility.recordPostState + `, ` + visibility.recordCommentState + `) AS record_state, + COALESCE(record_post.cid, record_comment.cid) AS record_cid + FROM notifications n` + visibility.joins + ` + LEFT JOIN notification_state state ON state.did = n.recipient_did + WHERE n.recipient_did = $1::text + AND ($2::timestamptz IS NULL OR (n.sort_at, n.id) < ($2::timestamptz, $3::bigint)) + AND CASE WHEN (` + visibility.visible + `) + THEN true ELSE false END + ORDER BY n.sort_at DESC, n.id DESC + LIMIT $4::int` +} + +var _ notifications.ReadRepository = (*postgresNotificationRepo)(nil) + +// List pages all visible notifications with the four parameters of listNotificationsSQL. +// With NULL seen_at, only the newest visible row of any reason is +// unread. A repeatable-read snapshot keeps SeenAt and row read states aligned. +func (r *postgresNotificationRepo) List(ctx context.Context, recipientDID, cursor string, limit int) (notifications.ListPage, error) { + if limit <= 0 || limit > maximumNotificationListLimit { + return notifications.ListPage{}, fmt.Errorf("list notifications: invalid limit %d", limit) + } + var sortAt any + var id any + if cursor != "" { + position, err := notifications.DecodeCursor(cursor) + if err != nil { + return notifications.ListPage{}, fmt.Errorf("list notifications: %w", err) + } + sortAt, id = position.SortAt, position.ID + } + + page := notifications.ListPage{} + transaction, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + if err != nil { + return page, fmt.Errorf("begin notification list snapshot: %w", err) + } + defer transaction.Rollback() + var seenAt sql.NullTime + if err := transaction.QueryRowContext(ctx, `SELECT seen_at FROM notification_state WHERE did = $1`, recipientDID).Scan(&seenAt); err != nil && !errors.Is(err, sql.ErrNoRows) { + return page, fmt.Errorf("read notification seen time: %w", err) + } + if seenAt.Valid { + seenTime := seenAt.Time.UTC() + page.SeenAt = &seenTime + } + + rows, err := transaction.QueryContext(ctx, r.listSQL, recipientDID, sortAt, id, limit+1) + if err != nil { + return notifications.ListPage{}, fmt.Errorf("list notifications: %w", err) + } + defer rows.Close() + for rows.Next() { + var notification notifications.ListedNotification + var recordURI, actorDID, subjectURI, rootPostURI sql.NullString + var rootState, rootCID, subjectState, subjectCID, recordState, recordCID sql.NullString + var recordCreatedAt sql.NullTime + if err := rows.Scan(¬ification.ID, ¬ification.Reason, &recordURI, &actorDID, + &subjectURI, &rootPostURI, &recordCreatedAt, ¬ification.SortAt, ¬ification.IsRead, + &rootState, &rootCID, &subjectState, &subjectCID, &recordState, &recordCID); err != nil { + return notifications.ListPage{}, fmt.Errorf("scan listed notification: %w", err) + } + notification.RootPost, err = listedReference(rootState, rootCID) + if err != nil { + return notifications.ListPage{}, fmt.Errorf("listed notification root post: %w", err) + } + if notification.Reason != notifications.ReasonUpvote { + notification.Record, err = listedReference(recordState, recordCID) + if err != nil { + return notifications.ListPage{}, fmt.Errorf("listed notification record: %w", err) + } + } + if notification.Reason != notifications.ReasonMention { + notification.Subject, err = listedReference(subjectState, subjectCID) + if err != nil { + return notifications.ListPage{}, fmt.Errorf("listed notification subject: %w", err) + } + } + notification.RecordURI = recordURI.String + notification.ActorDID = actorDID.String + notification.SubjectURI = subjectURI.String + notification.RootPostURI = rootPostURI.String + if recordCreatedAt.Valid { + notification.RecordCreatedAt = recordCreatedAt.Time.UTC() + } + notification.SortAt = notification.SortAt.UTC() + page.Notifications = append(page.Notifications, notification) + } + if err := rows.Err(); err != nil { + return notifications.ListPage{}, fmt.Errorf("iterate listed notifications: %w", err) + } + if err := rows.Close(); err != nil { + return notifications.ListPage{}, fmt.Errorf("close listed notifications: %w", err) + } + if len(page.Notifications) > limit { + page.Notifications = page.Notifications[:limit] + last := page.Notifications[limit-1] + page.Cursor = notifications.EncodeCursor(notifications.Cursor{SortAt: last.SortAt, ID: last.ID}) + } + var subjects []string + indices := make(map[string]int) + for index, notification := range page.Notifications { + if notification.Reason == notifications.ReasonUpvote { + subjects = append(subjects, notification.SubjectURI) + indices[notification.SubjectURI] = index + } + } + if len(subjects) > 0 { + upvotes, err := transaction.QueryContext(ctx, r.listUpvotesSQL, recipientDID, pq.Array(subjects)) + if err != nil { + return notifications.ListPage{}, fmt.Errorf("list notification upvotes: %w", err) + } + defer upvotes.Close() + for upvotes.Next() { + var subject string + var count int + var voters pq.StringArray + if err := upvotes.Scan(&subject, &count, &voters); err != nil { + return notifications.ListPage{}, fmt.Errorf("scan notification upvotes: %w", err) + } + row := &page.Notifications[indices[subject]] + row.UpvoteCount = count + row.RecentUpvoterDIDs = voters + } + if err := upvotes.Err(); err != nil { + return notifications.ListPage{}, fmt.Errorf("iterate notification upvotes: %w", err) + } + } + return page, nil +} + +func listedReference(state, cid sql.NullString) (notifications.ListedReference, error) { + if !state.Valid || !cid.Valid { + return notifications.ListedReference{}, fmt.Errorf("missing reference state or CID") + } + reference := notifications.ListedReference{CID: cid.String} + switch state.String { + case "live": + reference.State = notifications.ReferenceLive + case "deleted": + reference.State = notifications.ReferenceDeleted + case "removedByModerator": + reference.State = notifications.ReferenceRemovedByModerator + case "removedByServerAdmin": + reference.State = notifications.ReferenceRemovedByServerAdmin + default: + return notifications.ListedReference{}, fmt.Errorf("unexpected reference state %q", state.String) + } + return reference, nil +} diff --git a/internal/db/postgres/notification_list_bridged_agreement_test.go b/internal/db/postgres/notification_list_bridged_agreement_test.go new file mode 100644 index 0000000..2e11b99 --- /dev/null +++ b/internal/db/postgres/notification_list_bridged_agreement_test.go @@ -0,0 +1,150 @@ +//go:build integration + +package postgres + +import ( + "context" + "strconv" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func bridgedAgreementNotificationID(t *testing.T, f *unreadVisibilityFixture, reason, uri string) int64 { + t.Helper() + var id int64 + if reason == "upvote" { + require.NoError(t, f.db.QueryRow(`SELECT id FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, f.recipient, uri).Scan(&id)) + } else { + require.NoError(t, f.db.QueryRow(`SELECT id FROM notifications WHERE recipient_did = $1 AND record_uri = $2`, f.recipient, uri).Scan(&id)) + } + return id +} + +func TestNotificationList_BridgedEveryReasonPagesAgreeWithUnread(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := f.sortAt + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Microsecond)) + ids := map[string]int64{} + addRecord := func(label, reason, record, subject, root string, at time.Time) { + f.listNotificationAt(t, f.recipient, f.actor, reason, record, subject, root, at) + ids[label] = bridgedAgreementNotificationID(t, f, reason, record) + } + addGroup := func(label, subject, root string, at time.Time) int64 { + f.listGroupAt(t, subject, root, at) + id := bridgedAgreementNotificationID(t, f, "upvote", subject) + ids[label] = id + return id + } + for number := 1; number <= 12; number++ { + record, id := f.insertListedReply(t, f.recipient, "postReply", f.root, base.Add(time.Duration(number)*time.Second), base) + require.NotEmpty(t, record) + ids["R"+strconv.Itoa(number)] = id + } + postMention := seedVisibilityPost(t, f.db, f.community, f.actor, testkit.TID(), "mention post", base) + seedVisibilityAdmission(t, f.db, f.community, postMention, posts.AdmissionStatusAccepted, "", "") + addRecord("post mention", "mention", postMention, "", postMention, base.Add(13*time.Second)) + commentMention := f.comment(t, f.root) + addRecord("comment mention", "mention", commentMention, "", f.root, base.Add(14*time.Second)) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), f.root, "up", base) + addGroup("post group", f.root, f.root, base.Add(15*time.Second)) + commentSubject := seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), base) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), commentSubject, "up", base) + addGroup("comment group", commentSubject, f.root, base.Add(16*time.Second)) + deletedRecord := f.comment(t, f.root) + f.deleteComment(t, deletedRecord) + addRecord("deleted record", "commentReply", deletedRecord, f.comment(t, f.root), f.root, base.Add(17*time.Second)) + deletedSubjectPost := f.post(t, posts.AdmissionStatusAccepted, false) + f.deletePost(t, deletedSubjectPost) + seedWithdrawalMarker(t, f.db, deletedSubjectPost, "authorDelete", nil) + addRecord("deleted subject post", "postReply", f.comment(t, f.root), deletedSubjectPost, f.root, base.Add(18*time.Second)) + deletedSubjectComment := f.comment(t, f.root) + f.deleteComment(t, deletedSubjectComment) + addRecord("deleted subject comment", "commentReply", f.comment(t, f.root), deletedSubjectComment, f.root, base.Add(19*time.Second)) + removedRoot := f.post(t, posts.AdmissionStatusRemoved, false) + seedWithdrawalMarker(t, f.db, removedRoot, "communityWithdrawal", "3lqqqqqqqqqq1") + addRecord("removed root", "commentReply", f.comment(t, removedRoot), f.comment(t, removedRoot), removedRoot, base.Add(20*time.Second)) + deletedRoot := f.post(t, posts.AdmissionStatusAccepted, false) + f.deletePost(t, deletedRoot) + seedWithdrawalMarker(t, f.db, deletedRoot, "authorDelete", nil) + addRecord("deleted root", "commentReply", f.comment(t, deletedRoot), f.comment(t, deletedRoot), deletedRoot, base.Add(21*time.Second)) + deletedGroupComment := seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), base) + f.deleteComment(t, deletedGroupComment) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), deletedGroupComment, "up", base) + addGroup("deleted comment group", deletedGroupComment, f.root, base.Add(22*time.Second)) + removedGroupPost := f.post(t, posts.AdmissionStatusRemoved, false) + seedWithdrawalMarker(t, f.db, removedGroupPost, "communityWithdrawal", "3lqqqqqqqqqq1") + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), removedGroupPost, "up", base) + addGroup("removed post group", removedGroupPost, removedGroupPost, base.Add(23*time.Second)) + // Hidden records and groups interleave with the visible history. + pending := f.post(t, posts.AdmissionStatusPending, false) + f.listNotificationAt(t, f.recipient, f.actor, "postReply", f.comment(t, f.root), pending, pending, base.Add(18*time.Second+time.Microsecond)) + f.listNotificationAt(t, f.recipient, f.actor, "commentReply", "at://"+f.actor+"/social.coves.community.comment/"+testkit.TID(), f.comment(t, f.root), f.root, base.Add(14*time.Second+time.Microsecond)) + blockedSubject := f.post(t, posts.AdmissionStatusAccepted, false) + blockedVoter := "did:plc:listvoter" + testkit.UniqueID(t) + f.listVoteAt(t, blockedVoter, blockedSubject, "up", base) + f.insertBlock(t, f.recipient, blockedVoter) + f.listGroupAt(t, blockedSubject, blockedSubject, base.Add(20*time.Second+time.Microsecond)) + emptySubject := f.post(t, posts.AdmissionStatusAccepted, false) + f.listGroupAt(t, emptySubject, emptySubject, base.Add(21*time.Second+time.Microsecond)) + bridgedOnly := f.post(t, posts.AdmissionStatusAccepted, false) + setBridgedGroupTotals(t, f.db, "post", bridgedOnly, 5, 0) + bridgedOnlyID := addGroup("bridged only", bridgedOnly, bridgedOnly, base.Add(24*time.Second)) + mixed := seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), base) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), mixed, "up", base) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), mixed, "up", base.Add(time.Second)) + setBridgedGroupTotals(t, f.db, "comment", mixed, 5, 0) + mixedID := addGroup("mixed", mixed, f.root, base.Add(25*time.Second)) + wantLabels := []string{ + "R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9", "R10", "R11", "R12", + "post mention", "comment mention", "post group", "comment group", "deleted record", + "deleted subject post", "deleted subject comment", "removed root", "deleted root", + "deleted comment group", "removed post group", "bridged only", "mixed", + } + require.Len(t, ids, 25) + wantIDs := make([]int64, 0, 25) + for _, label := range wantLabels { + id, ok := ids[label] + require.True(t, ok, "missing fixture label %s", label) + wantIDs = append(wantIDs, id) + } + + repository := NewNotificationRepository(f.db, WithBridgedUpvoteTotals()).(notifications.ReadRepository) + count, err := repository.CountUnread(context.Background(), f.recipient) + require.NoError(t, err) + var gotIDs []int64 + var bridgedOnlyCount, mixedCount int + cursor := "" + for { + page, err := repository.List(context.Background(), f.recipient, cursor, 6) + require.NoError(t, err) + for _, row := range page.Notifications { + gotIDs = append(gotIDs, row.ID) + switch row.ID { + case bridgedOnlyID: + bridgedOnlyCount = row.UpvoteCount + require.Empty(t, row.RecentUpvoterDIDs) + case mixedID: + mixedCount = row.UpvoteCount + require.Len(t, row.RecentUpvoterDIDs, 2) + } + } + if page.Cursor == "" { + break + } + require.NotEqual(t, cursor, page.Cursor) + cursor = page.Cursor + require.Less(t, len(gotIDs), 101, "pagination must terminate") + } + require.ElementsMatch(t, wantIDs, gotIDs, "all 25 seeded visible IDs, including both bridged groups") + require.Equal(t, 25, count) + require.Equal(t, count, len(gotIDs), "pages and CountUnread must agree") + require.Equal(t, 5, bridgedOnlyCount) + require.Equal(t, 7, mixedCount) +} diff --git a/internal/db/postgres/notification_list_mention_visibility_test.go b/internal/db/postgres/notification_list_mention_visibility_test.go new file mode 100644 index 0000000..83e0442 --- /dev/null +++ b/internal/db/postgres/notification_list_mention_visibility_test.go @@ -0,0 +1,117 @@ +//go:build integration + +package postgres + +import ( + "testing" + "time" + + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationList_AgreesWithCountForMentionRows(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, kind, position string + status posts.AdmissionStatus + drifted bool + wantLabels []string + wantCount int + }{ + {"post source pending", "post", "", posts.AdmissionStatusPending, false, []string{"control"}, 1}, + {"post source rejected", "post", "", posts.AdmissionStatusRejected, false, []string{"control"}, 1}, + {"post source pending reacceptance", "post", "", posts.AdmissionStatusPendingReacceptance, false, []string{"control"}, 1}, + {"post source no admission", "post", "", "", false, []string{"control"}, 1}, + {"post source CID drifted", "post", "", "", true, []string{"control"}, 1}, + {"post source deleted without marker", "deletedPost", "", posts.AdmissionStatusAccepted, false, []string{"control"}, 1}, + {"post source removed without marker", "post", "", posts.AdmissionStatusRemoved, false, []string{"control"}, 1}, + {"post source unindexed", "unindexedPost", "", "", false, []string{"control"}, 1}, + {"comment source unindexed", "unindexedComment", "", "", false, []string{"control"}, 1}, + {"mention record in unsupported collection", "unsupportedCollection", "", "", false, []string{"control"}, 1}, + {"comment root pending", "commentRoot", "", posts.AdmissionStatusPending, false, []string{"control"}, 1}, + {"post source author-deleted with marker", "deletedPostMarked", "", posts.AdmissionStatusAccepted, false, []string{"case", "control"}, 2}, + {"post source community-removed with marker", "removedPostMarked", "", posts.AdmissionStatusRemoved, false, []string{"case", "control"}, 2}, + {"comment source deleted", "deletedComment", "", "", false, []string{"case", "control"}, 2}, + {"comment root author-deleted with marker", "deletedRootMarked", "", posts.AdmissionStatusAccepted, false, []string{"case", "control"}, 2}, + {"comment root community-removed with marker", "removedRootMarked", "", posts.AdmissionStatusRemoved, false, []string{"case", "control"}, 2}, + {"post source live", "post", "", posts.AdmissionStatusAccepted, false, []string{"case", "control"}, 2}, + {"comment source live", "comment", "", "", false, []string{"case", "control"}, 2}, + {"recipient blocks mention actor", "block", "recipient", "", false, []string{"control"}, 1}, + {"mention actor blocks recipient", "block", "actor", "", false, []string{"control"}, 1}, + {"disabled mention", "disabled", "", "", false, []string{"control"}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + control := f.listControlRecord(t) // A non-mention reply survives even when mentions are disabled. + labels := map[string]string{control: "control"} + actor := f.actor + if tc.kind == "block" { + actor = "did:plc:listmentioncase" + testkit.UniqueID(t) + } + root := f.root + var record string + switch tc.kind { + case "post", "deletedPost", "deletedPostMarked", "removedPostMarked": + rkey := testkit.TID() + record = seedVisibilityPost(t, f.db, f.community, actor, rkey, "mention post", f.sortAt) + if tc.status != "" { + seedVisibilityAdmission(t, f.db, f.community, record, tc.status, "", "") + } + if tc.drifted { + seedVisibilityAdmissionDriftedCID(t, f.db, f.community, record) + } + if tc.kind == "deletedPost" || tc.kind == "deletedPostMarked" { + f.deletePost(t, record) + } + if tc.kind == "deletedPostMarked" { + seedWithdrawalMarker(t, f.db, record, "authorDelete", nil) + } + if tc.kind == "removedPostMarked" { + seedWithdrawalMarker(t, f.db, record, "communityWithdrawal", "3lqqqqqqqqqq1") + } + root = record // A post mention points to itself as both record and root. + case "unindexedPost": + record = postV2URI(actor, testkit.TID()) + root = record + case "commentRoot", "deletedRootMarked", "removedRootMarked": + root = f.post(t, tc.status, false) + if tc.kind == "deletedRootMarked" { + f.deletePost(t, root) + seedWithdrawalMarker(t, f.db, root, "authorDelete", nil) + } + if tc.kind == "removedRootMarked" { + seedWithdrawalMarker(t, f.db, root, "communityWithdrawal", "3lqqqqqqqqqq1") + } + record = seedActorComment(t, f.db, actor, root, testkit.TID(), f.sortAt) + case "unindexedComment": + record = "at://" + actor + "/social.coves.community.comment/" + testkit.TID() + case "unsupportedCollection": + record = "at://" + actor + "/social.coves.feed.vote/" + testkit.TID() + default: + record = seedActorComment(t, f.db, actor, root, testkit.TID(), f.sortAt) + if tc.kind == "deletedComment" { + f.deleteComment(t, record) + } + if tc.kind == "block" { + if tc.position == "recipient" { + f.insertBlock(t, f.recipient, actor) + } else { + f.insertBlock(t, actor, f.recipient) + } + } + if tc.kind == "disabled" { + f.setDisabledReasons(t, f.recipient, []string{"mention"}) + } + } + f.listNotificationAt(t, f.recipient, actor, "mention", record, "", root, f.sortAt.Add(time.Second)) + labels[record] = "case" + f.requireCount(t, tc.wantCount) + page := f.listPage(t, f.recipient, "", 10) + require.Equal(t, tc.wantLabels, f.recordLabels(t, page, labels)) + require.Empty(t, page.Cursor) + }) + } +} diff --git a/internal/db/postgres/notification_list_reference_states_test.go b/internal/db/postgres/notification_list_reference_states_test.go new file mode 100644 index 0000000..bcd4955 --- /dev/null +++ b/internal/db/postgres/notification_list_reference_states_test.go @@ -0,0 +1,274 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +// Set the fixture's current content CID explicitly so the expectations do not +// depend on its generated rkeys. Keep accepted admissions pinned to live posts. +func setListedPostCID(t *testing.T, f *unreadVisibilityFixture, uri, cid string) { + t.Helper() + _, err := f.db.ExecContext(context.Background(), `UPDATE posts SET cid = $2 WHERE uri = $1`, uri, cid) + require.NoError(t, err) + _, err = f.db.ExecContext(context.Background(), `UPDATE community_post_admissions + SET accepted_cid = $2, evaluated_cid = $2 WHERE post_uri = $1 AND status = 'accepted'`, uri, cid) + require.NoError(t, err) +} + +func setListedCommentCID(t *testing.T, f *unreadVisibilityFixture, uri, cid string) { + t.Helper() + _, err := f.db.ExecContext(context.Background(), `UPDATE comments SET cid = $2 WHERE uri = $1`, uri, cid) + require.NoError(t, err) +} + +// Each case lists one notification and counts it against a seen_at just before +// it, so a row that is listed is counted exactly once and a hidden row is neither +// listed nor counted. Admin moderation decisions are seeded in main's moderation +// tables; the root post is the subject for postReply and upvote rows. +func TestNotificationList_ReferenceStatesAndCurrentCIDs(t *testing.T) { + t.Parallel() + serverAdminRemoved := func(cid string) notifications.ListedReference { + return notifications.ListedReference{State: notifications.ReferenceRemovedByServerAdmin, CID: cid} + } + live := func(cid string) notifications.ListedReference { + return notifications.ListedReference{State: notifications.ReferenceLive, CID: cid} + } + for _, tc := range []struct { + name, reason, rootState, subjectState, recordState string + rootCID, subjectCID, recordCID string + hidden bool + wantRoot, wantSubject, wantRecord notifications.ListedReference + }{ + { + name: "author-deleted postReply subject and root", reason: "postReply", rootState: "authorDeleted", + rootCID: "bafydeletedpostcurrent", subjectCID: "bafydeletedpostcurrent", recordCID: "bafydeletedpostreplycurrent", + wantRoot: notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeletedpostcurrent"}, + wantSubject: notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeletedpostcurrent"}, + wantRecord: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafydeletedpostreplycurrent"}, + }, + { + name: "community-removed root and deleted comment subject", reason: "commentReply", rootState: "communityRemoved", subjectState: "deleted", + rootCID: "bafyremovedrootcurrent", subjectCID: "bafydeletedsubjectcurrent", recordCID: "bafyremovedrootreplycurrent", + wantRoot: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyremovedrootcurrent"}, + wantSubject: notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeletedsubjectcurrent"}, + wantRecord: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyremovedrootreplycurrent"}, + }, + { + name: "community-removed and author-deleted postReply root", reason: "postReply", rootState: "communityRemovedAndAuthorDeleted", + rootCID: "bafyremoveddeletedcurrent", subjectCID: "bafyremoveddeletedcurrent", recordCID: "bafyremoveddeletedreplycurrent", + wantRoot: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyremoveddeletedcurrent"}, + wantSubject: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyremoveddeletedcurrent"}, + wantRecord: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyremoveddeletedreplycurrent"}, + }, + { + name: "all references live", reason: "commentReply", + rootCID: "bafyliverootcurrent", subjectCID: "bafylivesubjectcurrent", recordCID: "bafylivereplycurrent", + wantRoot: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyliverootcurrent"}, + wantSubject: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylivesubjectcurrent"}, + wantRecord: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafylivereplycurrent"}, + }, + { + name: "server-admin-removed postReply subject", reason: "postReply", rootState: "instanceRemoved", + rootCID: "bafyadminremovedpost", subjectCID: "bafyadminremovedpost", recordCID: "bafyadminremovedpostreply", + wantRoot: serverAdminRemoved("bafyadminremovedpost"), wantSubject: serverAdminRemoved("bafyadminremovedpost"), + wantRecord: serverAdminRemoved("bafyadminremovedpostreply"), + }, + { + name: "server-admin-removed upvote subject", reason: "upvote", rootState: "instanceRemoved", + rootCID: "bafyadminremovedupvoted", subjectCID: "bafyadminremovedupvoted", + wantRoot: serverAdminRemoved("bafyadminremovedupvoted"), wantSubject: serverAdminRemoved("bafyadminremovedupvoted"), + }, + { + name: "community-scope-only removal of postReply subject", reason: "postReply", rootState: "communityScopeRemoved", + rootCID: "bafyscoperemovedpost", subjectCID: "bafyscoperemovedpost", recordCID: "bafyscoperemovedpostreply", + wantRoot: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyscoperemovedpost"}, + wantSubject: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyscoperemovedpost"}, + wantRecord: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyscoperemovedpostreply"}, + }, + { + name: "instance and community-scope removals of postReply subject", reason: "postReply", rootState: "instanceAndCommunityScopeRemoved", + rootCID: "bafybothremovedpost", subjectCID: "bafybothremovedpost", recordCID: "bafybothremovedpostreply", + wantRoot: serverAdminRemoved("bafybothremovedpost"), wantSubject: serverAdminRemoved("bafybothremovedpost"), + wantRecord: serverAdminRemoved("bafybothremovedpostreply"), + }, + { + name: "server-admin removal of community-withdrawn postReply subject", reason: "postReply", rootState: "communityRemovedAndInstanceRemoved", + rootCID: "bafywithdrawnadminpost", subjectCID: "bafywithdrawnadminpost", recordCID: "bafywithdrawnadminpostreply", + wantRoot: serverAdminRemoved("bafywithdrawnadminpost"), wantSubject: serverAdminRemoved("bafywithdrawnadminpost"), + wantRecord: serverAdminRemoved("bafywithdrawnadminpostreply"), + }, + { + name: "inactive server-admin removal of postReply subject", reason: "postReply", rootState: "instanceRemovalInactive", + rootCID: "bafyrestoredpost", subjectCID: "bafyrestoredpost", recordCID: "bafyrestoredpostreply", + wantRoot: live("bafyrestoredpost"), wantSubject: live("bafyrestoredpost"), wantRecord: live("bafyrestoredpostreply"), + }, + { + name: "label-only decision on postReply subject", reason: "postReply", rootState: "instanceLabel", + rootCID: "bafylabeledpost", subjectCID: "bafylabeledpost", recordCID: "bafylabeledpostreply", + wantRoot: live("bafylabeledpost"), wantSubject: live("bafylabeledpost"), wantRecord: live("bafylabeledpostreply"), + }, + { + name: "pending postReply subject with server-admin removal", reason: "postReply", rootState: "pendingInstanceRemoved", + rootCID: "bafypendingadminpost", subjectCID: "bafypendingadminpost", recordCID: "bafypendingadminpostreply", + hidden: true, + }, + { + name: "author-deleted postReply subject with server-admin removal", reason: "postReply", rootState: "authorDeletedAndInstanceRemoved", + rootCID: "bafydeletedadminpost", subjectCID: "bafydeletedadminpost", recordCID: "bafydeletedadminpostreply", + wantRoot: notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeletedadminpost"}, + wantSubject: notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeletedadminpost"}, + wantRecord: serverAdminRemoved("bafydeletedadminpostreply"), + }, + { + name: "author-deleted postReply subject without marker with server-admin removal", reason: "postReply", rootState: "authorDeletedWithoutMarkerAndInstanceRemoved", + rootCID: "bafyunmarkedadminpost", subjectCID: "bafyunmarkedadminpost", recordCID: "bafyunmarkedadminpostreply", + hidden: true, + }, + { + name: "commentReply under server-admin-removed root", reason: "commentReply", rootState: "instanceRemoved", + rootCID: "bafyadminremovedthread", subjectCID: "bafyadminremovedthreadsubject", recordCID: "bafyadminremovedthreadreply", + wantRoot: serverAdminRemoved("bafyadminremovedthread"), wantSubject: serverAdminRemoved("bafyadminremovedthreadsubject"), + wantRecord: serverAdminRemoved("bafyadminremovedthreadreply"), + }, + { + name: "commentReply under community-scope-only removed root", reason: "commentReply", rootState: "communityScopeRemoved", + rootCID: "bafyscoperemovedthread", subjectCID: "bafyscoperemovedthreadsubject", recordCID: "bafyscoperemovedthreadreply", + wantRoot: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyscoperemovedthread"}, + wantSubject: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyscoperemovedthreadsubject"}, + wantRecord: notifications.ListedReference{State: notifications.ReferenceRemovedByModerator, CID: "bafyscoperemovedthreadreply"}, + }, + { + name: "author-deleted commentReply subject under server-admin-removed root", reason: "commentReply", rootState: "instanceRemoved", subjectState: "deleted", + rootCID: "bafyadminremoveddeletedthread", subjectCID: "bafyadminremoveddeletedsubject", recordCID: "bafyadminremoveddeletedreply", + wantRoot: serverAdminRemoved("bafyadminremoveddeletedthread"), + wantSubject: notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafyadminremoveddeletedsubject"}, + wantRecord: serverAdminRemoved("bafyadminremoveddeletedreply"), + }, + { + name: "server-admin-removed commentReply subject", reason: "commentReply", subjectState: "instanceRemoved", + rootCID: "bafyadmincommentroot", subjectCID: "bafyadminremovedcomment", recordCID: "bafyadmincommentreply", + wantRoot: live("bafyadmincommentroot"), wantSubject: serverAdminRemoved("bafyadminremovedcomment"), + wantRecord: live("bafyadmincommentreply"), + }, + { + name: "author-deleted commentReply subject with server-admin removal", reason: "commentReply", subjectState: "deletedAndInstanceRemoved", + rootCID: "bafydeletedadmincommentroot", subjectCID: "bafydeletedadmincomment", recordCID: "bafydeletedadmincommentreply", + wantRoot: live("bafydeletedadmincommentroot"), + wantSubject: notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeletedadmincomment"}, + wantRecord: live("bafydeletedadmincommentreply"), + }, + { + name: "server-admin-removed reply comment record", reason: "postReply", recordState: "instanceRemoved", + rootCID: "bafyadminrecordroot", subjectCID: "bafyadminrecordroot", recordCID: "bafyadminremovedreply", + wantRoot: live("bafyadminrecordroot"), wantSubject: live("bafyadminrecordroot"), + wantRecord: serverAdminRemoved("bafyadminremovedreply"), + }, + { + name: "server-admin-removed post mention record", reason: "mention", recordState: "instanceRemoved", + rootCID: "bafyadminmentionroot", recordCID: "bafyadminremovedmention", + wantRoot: live("bafyadminmentionroot"), wantRecord: serverAdminRemoved("bafyadminremovedmention"), + }, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadCountFixture(t) + retentionSeenAt(t, f.db, f.recipient, f.sortAt.Add(-time.Microsecond)) + root := f.root + switch tc.rootState { + case "communityRemoved", "communityRemovedAndAuthorDeleted", "communityRemovedAndInstanceRemoved": + root = f.post(t, posts.AdmissionStatusRemoved, false) + case "pendingInstanceRemoved": + root = f.post(t, posts.AdmissionStatusPending, false) + } + setListedPostCID(t, f, root, tc.rootCID) + switch tc.rootState { + case "authorDeleted": + f.deletePost(t, root) + seedWithdrawalMarker(t, f.db, root, "authorDelete", nil) + case "communityRemoved": + seedWithdrawalMarker(t, f.db, root, "communityWithdrawal", "3lqqqqqqqqqq1") + case "communityRemovedAndAuthorDeleted": + f.deletePost(t, root) + seedWithdrawalMarker(t, f.db, root, "communityWithdrawal", "3lqqqqqqqqqq1") + seedWithdrawalMarker(t, f.db, root, "authorDelete", nil) + case "instanceRemoved", "pendingInstanceRemoved": + seedModerationDecision(t, f.db, root, "removal", "", true) + case "communityScopeRemoved": + seedModerationDecision(t, f.db, root, "removal", f.community, true) + case "instanceAndCommunityScopeRemoved": + seedModerationDecision(t, f.db, root, "removal", "", true) + seedModerationDecision(t, f.db, root, "removal", f.community, true) + case "communityRemovedAndInstanceRemoved": + seedWithdrawalMarker(t, f.db, root, "communityWithdrawal", "3lqqqqqqqqqq1") + seedModerationDecision(t, f.db, root, "removal", "", true) + case "instanceRemovalInactive": + seedModerationDecision(t, f.db, root, "removal", "", false) + case "instanceLabel": + seedModerationDecision(t, f.db, root, "label", "", true) + case "authorDeletedAndInstanceRemoved": + f.deletePost(t, root) + seedWithdrawalMarker(t, f.db, root, "authorDelete", nil) + seedModerationDecision(t, f.db, root, "removal", "", true) + case "authorDeletedWithoutMarkerAndInstanceRemoved": + f.deletePost(t, root) + seedModerationDecision(t, f.db, root, "removal", "", true) + } + subject := root + switch tc.reason { + case "commentReply": + subject = f.comment(t, root) + setListedCommentCID(t, f, subject, tc.subjectCID) + switch tc.subjectState { + case "deleted": + f.deleteComment(t, subject) + case "instanceRemoved": + seedModerationDecision(t, f.db, subject, "removal", "", true) + case "deletedAndInstanceRemoved": + f.deleteComment(t, subject) + seedModerationDecision(t, f.db, subject, "removal", "", true) + } + case "mention": + subject = "" + } + var record string + switch tc.reason { + case "upvote": + f.insertVote(t, "did:plc:referencevoter"+testkit.UniqueID(t), subject, false) + case "mention": + record = f.post(t, posts.AdmissionStatusAccepted, false) + setListedPostCID(t, f, record, tc.recordCID) + default: + record = f.comment(t, root) + setListedCommentCID(t, f, record, tc.recordCID) + } + if tc.recordState == "instanceRemoved" { + seedModerationDecision(t, f.db, record, "removal", "", true) + } + // listNotificationAt stores bafyunreadrecord, never the current record CID. + f.listNotificationAt(t, f.recipient, f.actor, tc.reason, record, subject, root, f.sortAt) + page := f.listPage(t, f.recipient, "", 10) + if tc.hidden { + require.Empty(t, page.Notifications) + f.requireCount(t, 0) + return + } + require.Len(t, page.Notifications, 1) + listed := page.Notifications[0] + require.Equal(t, record, listed.RecordURI) + require.Equal(t, subject, listed.SubjectURI) + require.Equal(t, tc.wantRoot, listed.RootPost) + require.Equal(t, tc.wantSubject, listed.Subject) + require.Equal(t, tc.wantRecord, listed.Record) + f.requireCount(t, 1) + }) + } +} diff --git a/internal/db/postgres/notification_list_test.go b/internal/db/postgres/notification_list_test.go new file mode 100644 index 0000000..e047cfc --- /dev/null +++ b/internal/db/postgres/notification_list_test.go @@ -0,0 +1,211 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + "time" + + "Coves/internal/core/notifications" + + "github.com/stretchr/testify/require" +) + +// insertListedReply inserts an indexed comment and its reply notification at +// the requested sort time. createdAt may be nil to exercise nullable projection. +func (f *unreadVisibilityFixture) insertListedReply(t *testing.T, recipient, reason, subject string, sortAt time.Time, createdAt any) (string, int64) { + t.Helper() + record := f.comment(t, f.root) + var id int64 + err := f.db.QueryRowContext(context.Background(), `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, $2, $3, 'bafyunreadrecord', $4, $5, $6, $7, $8) RETURNING id`, + recipient, reason, record, f.actor, subject, f.root, createdAt, sortAt.Truncate(time.Microsecond)).Scan(&id) + require.NoError(t, err) + return record, id +} + +func (f *unreadVisibilityFixture) listPage(t *testing.T, recipient, cursor string, limit int) notifications.ListPage { + t.Helper() + page, err := NewNotificationRepository(f.db).(notifications.ReadRepository).List(context.Background(), recipient, cursor, limit) + require.NoError(t, err) + return page +} + +// recordLabels translates only fixture-generated URIs into the labels assigned +// at insertion; expected label slices in the tests are independent literals. +func (f *unreadVisibilityFixture) recordLabels(t *testing.T, page notifications.ListPage, labels map[string]string) []string { + t.Helper() + got := make([]string, 0, len(page.Notifications)) + for _, notification := range page.Notifications { + label, ok := labels[notification.RecordURI] + require.True(t, ok, "unexpected record URI %q", notification.RecordURI) + got = append(got, label) + } + return got +} + +func TestNotificationList_LimitAndOrder(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Microsecond)) + labels := make(map[string]string) + add := func(label, reason, subject string, at time.Time, createdAt any) (string, int64) { + record, id := f.insertListedReply(t, f.recipient, reason, subject, at, createdAt) + labels[record] = label + return record, id + } + add("A", "postReply", f.root, base, base) + add("Bb", "postReply", f.root, base.Add(time.Second), base) + commentSubject := f.comment(t, f.root) + commentRecord, commentID := add("C", "commentReply", commentSubject, base.Add(time.Second), base.Add(-time.Hour)) + postRecord, postID := add("D", "postReply", f.root, base.Add(2*time.Second), nil) + add("E", "postReply", f.root, base.Add(3*time.Second), base) + + otherRecipient := retentionUser(t, f.db) + f.insertListedReply(t, otherRecipient, "postReply", f.root, base.Add(4*time.Second), base) + f.insertListedReply(t, otherRecipient, "postReply", f.root, base.Add(5*time.Second), base) + + first := f.listPage(t, f.recipient, "", 3) + require.Equal(t, []string{"E", "D", "C"}, f.recordLabels(t, first, labels)) + require.NotEmpty(t, first.Cursor) + all := f.listPage(t, f.recipient, "", 10) + require.Equal(t, []string{"E", "D", "C", "Bb", "A"}, f.recordLabels(t, all, labels)) + require.Empty(t, all.Cursor) + + comment := all.Notifications[2] + require.Equal(t, commentID, comment.ID) + require.Equal(t, notifications.ReasonCommentReply, comment.Reason) + require.Equal(t, commentRecord, comment.RecordURI) + require.Equal(t, f.actor, comment.ActorDID) + require.Equal(t, commentSubject, comment.SubjectURI) + require.NotEqual(t, f.root, comment.SubjectURI) + require.Equal(t, f.root, comment.RootPostURI) + require.True(t, comment.RecordCreatedAt.Equal(base.Add(-time.Hour))) + require.True(t, comment.SortAt.Equal(base.Add(time.Second))) + + post := all.Notifications[1] + require.Equal(t, postID, post.ID) + require.Equal(t, postRecord, post.RecordURI) + require.Equal(t, notifications.ReasonPostReply, post.Reason) + require.True(t, post.RecordCreatedAt.IsZero()) +} + +func TestNotificationList_KeysetPagingIsExact(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Microsecond)) + labels := make(map[string]string) + // Deliberately shuffled insertion times. Both four-row ties cross a page + // boundary, so a sort_at-only cursor loses rows even if page one is correct. + for _, row := range []struct { + label string + micros int + }{ + {"N25", 4}, {"N22", 7}, {"N18", 11}, {"N12", 17}, {"N08", 18}, + {"N21", 7}, {"N17", 12}, {"N11", 17}, {"N07", 19}, {"N20", 7}, + {"N16", 13}, {"N10", 17}, {"N06", 20}, {"N19", 7}, {"N15", 14}, + {"N09", 17}, {"N05", 21}, {"N24", 5}, {"N14", 15}, {"N04", 22}, + {"N23", 6}, {"N13", 16}, {"N03", 23}, {"N02", 24}, {"N01", 25}, + } { + record, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, base.Add(time.Duration(row.micros)*time.Microsecond), base) + labels[record] = row.label + } + + t.Run("10 10 5 pages without loss or duplication", func(t *testing.T) { + cursor := "" + got := make([]string, 0, 25) + for _, size := range []int{10, 10, 5} { + page := f.listPage(t, f.recipient, cursor, 10) + require.Len(t, page.Notifications, size) + got = append(got, f.recordLabels(t, page, labels)...) + if size == 5 { + require.Empty(t, page.Cursor) + } else { + require.NotEmpty(t, page.Cursor) + } + cursor = page.Cursor + } + require.Equal(t, []string{ + "N01", "N02", "N03", "N04", "N05", "N06", "N07", "N08", "N09", "N10", + "N11", "N12", "N13", "N14", "N15", "N16", "N17", "N18", "N19", "N20", + "N21", "N22", "N23", "N24", "N25", + }, got) + }) + t.Run("exact multiple ends without cursor", func(t *testing.T) { + page := f.listPage(t, f.recipient, "", 25) + require.Equal(t, []string{ + "N01", "N02", "N03", "N04", "N05", "N06", "N07", "N08", "N09", "N10", + "N11", "N12", "N13", "N14", "N15", "N16", "N17", "N18", "N19", "N20", + "N21", "N22", "N23", "N24", "N25", + }, f.recordLabels(t, page, labels)) + require.Empty(t, page.Cursor) + }) + t.Run("invalid cursor wraps domain error", func(t *testing.T) { + _, err := NewNotificationRepository(f.db).(notifications.ReadRepository).List(context.Background(), f.recipient, "not-a-cursor", 10) + require.ErrorIs(t, err, notifications.ErrInvalidCursor) + }) +} + +func TestNotificationList_IsReadAgainstSeenAt(t *testing.T) { + t.Parallel() + t.Run("before at and after stored seen time", func(t *testing.T) { + f := newUnreadCountFixture(t) + base := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + seenAt := base.Add(2 * time.Second) + retentionSeenAt(t, f.db, f.recipient, seenAt) + labels := make(map[string]string) + for _, row := range []struct { + label string + at time.Time + }{ + {"before", base.Add(time.Second)}, {"equal", seenAt}, + {"after", base.Add(3 * time.Second)}, {"newest", base.Add(4 * time.Second)}, + } { + record, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, row.at, base) + labels[record] = row.label + } + page := f.listPage(t, f.recipient, "", 10) + require.Equal(t, []string{"newest", "after", "equal", "before"}, f.recordLabels(t, page, labels)) + require.Equal(t, []bool{false, false, true, true}, []bool{ + page.Notifications[0].IsRead, page.Notifications[1].IsRead, + page.Notifications[2].IsRead, page.Notifications[3].IsRead, + }) + require.NotNil(t, page.SeenAt) + require.True(t, page.SeenAt.Equal(seenAt)) + require.Empty(t, page.Cursor) + }) + t.Run("microsecond equality is read", func(t *testing.T) { + f := newUnreadCountFixture(t) + base := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + seenAt := base.Add(2*time.Second + time.Microsecond) + retentionSeenAt(t, f.db, f.recipient, seenAt) + record, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, seenAt, base) + page := f.listPage(t, f.recipient, "", 10) + require.Equal(t, []string{"at microsecond"}, f.recordLabels(t, page, map[string]string{record: "at microsecond"})) + require.True(t, page.Notifications[0].IsRead) + require.NotNil(t, page.SeenAt) + require.True(t, page.SeenAt.Equal(seenAt)) + }) + t.Run("stored seen time on empty page", func(t *testing.T) { + f := newUnreadCountFixture(t) + seenAt := time.Date(2026, 9, 30, 12, 0, 2, 0, time.UTC).Truncate(time.Microsecond) + retentionSeenAt(t, f.db, f.recipient, seenAt) + page := f.listPage(t, f.recipient, "", 10) + require.Empty(t, page.Notifications) + require.Empty(t, page.Cursor) + require.NotNil(t, page.SeenAt) + require.True(t, page.SeenAt.Equal(seenAt)) + }) + t.Run("no state row returns reply and nil seen time", func(t *testing.T) { + f := newUnreadCountFixture(t) + base := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + record, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, base, base) + page := f.listPage(t, f.recipient, "", 10) + require.Equal(t, []string{"only reply"}, f.recordLabels(t, page, map[string]string{record: "only reply"})) + require.Nil(t, page.SeenAt) + }) +} diff --git a/internal/db/postgres/notification_list_upvote_statements_test.go b/internal/db/postgres/notification_list_upvote_statements_test.go new file mode 100644 index 0000000..e051d18 --- /dev/null +++ b/internal/db/postgres/notification_list_upvote_statements_test.go @@ -0,0 +1,211 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "strconv" + "sync" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +type listStatementCounter struct { + mu sync.Mutex + count int + hookAt int + hook func() +} + +func (c *listStatementCounter) beforeStatement() { + c.mu.Lock() + c.count++ + number, hook := c.count, c.hook + c.mu.Unlock() + if number == c.hookAt && hook != nil { + hook() + } +} + +func (c *listStatementCounter) reset(hookAt int, hook func()) { + c.mu.Lock() + defer c.mu.Unlock() + c.count, c.hookAt, c.hook = 0, hookAt, hook +} + +func (c *listStatementCounter) total() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.count +} + +type listCountingConnector struct { + underlying driver.Connector + counter *listStatementCounter +} + +func (c *listCountingConnector) Connect(ctx context.Context) (driver.Conn, error) { + connection, err := c.underlying.Connect(ctx) + if err != nil { + return nil, err + } + return &listCountingConn{Conn: connection, counter: c.counter}, nil +} + +func (c *listCountingConnector) Driver() driver.Driver { return c.underlying.Driver() } + +type listCountingConn struct { + driver.Conn + counter *listStatementCounter +} + +func (c *listCountingConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + c.counter.beforeStatement() + if queryer, ok := c.Conn.(driver.QueryerContext); ok { + return queryer.QueryContext(ctx, query, args) + } + values := make([]driver.Value, len(args)) + for index, arg := range args { + values[index] = arg.Value + } + return c.Conn.(driver.Queryer).Query(query, values) +} + +func (c *listCountingConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + c.counter.beforeStatement() + if executor, ok := c.Conn.(driver.ExecerContext); ok { + return executor.ExecContext(ctx, query, args) + } + values := make([]driver.Value, len(args)) + for index, arg := range args { + values[index] = arg.Value + } + return c.Conn.(driver.Execer).Exec(query, values) +} + +func (c *listCountingConn) Prepare(query string) (driver.Stmt, error) { + c.counter.beforeStatement() + return c.Conn.Prepare(query) +} + +func (c *listCountingConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) { + c.counter.beforeStatement() + if preparer, ok := c.Conn.(driver.ConnPrepareContext); ok { + return preparer.PrepareContext(ctx, query) + } + return c.Conn.Prepare(query) +} + +func (c *listCountingConn) BeginTx(ctx context.Context, options driver.TxOptions) (driver.Tx, error) { + if beginner, ok := c.Conn.(driver.ConnBeginTx); ok { + return beginner.BeginTx(ctx, options) + } + return c.Conn.Begin() +} + +func listCountingRepository(t *testing.T, source *sql.DB) (notifications.ReadRepository, *listStatementCounter) { + t.Helper() + var database string + require.NoError(t, source.QueryRowContext(context.Background(), `SELECT current_database()`).Scan(&database)) + connector, err := pq.NewConnector(testkit.Endpoints().Postgres.URL(database)) + require.NoError(t, err) + counter := &listStatementCounter{} + db := sql.OpenDB(&listCountingConnector{underlying: connector, counter: counter}) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + return NewNotificationRepository(db).(notifications.ReadRepository), counter +} + +func TestNotificationList_UpvoteAggregatesUseBoundedStatements(t *testing.T) { + t.Parallel() + var statementTotals []int + for _, size := range []int{50, 8} { + t.Run(strconv.Itoa(size)+" mixed rows", func(t *testing.T) { + f := newUnreadCountFixture(t) + retentionSeenAt(t, f.db, f.recipient, f.sortAt.Add(-time.Second)) + voters := map[string]string{} + for i := 0; i < size; i++ { + at := f.sortAt.Add(time.Duration(i+1) * time.Second) + switch i % 4 { + case 0: + post := f.post(t, posts.AdmissionStatusAccepted, false) + voter := "did:plc:listvoter" + testkit.UniqueID(t) + f.listVoteAt(t, voter, post, "up", at) + f.listGroupAt(t, post, post, at) + voters[post] = voter + case 1: + f.insertListedReply(t, f.recipient, "postReply", f.root, at, f.sortAt) + case 2: + f.insertListedReply(t, f.recipient, "commentReply", f.comment(t, f.root), at, f.sortAt) + case 3: + record := f.comment(t, f.root) + f.listNotificationAt(t, f.recipient, f.actor, "mention", record, "", f.root, at) + } + } + repository, counter := listCountingRepository(t, f.db) + page, err := repository.List(context.Background(), f.recipient, "", size) + require.NoError(t, err) + require.Equal(t, size, len(page.Notifications), "all reasons must fill the mixed page") + require.Len(t, voters, (size+3)/4) + for _, row := range page.Notifications { + if row.Reason != notifications.ReasonUpvote { + continue + } + require.Equal(t, 1, row.UpvoteCount, "group %s", row.SubjectURI) + require.Equal(t, []string{voters[row.SubjectURI]}, row.RecentUpvoterDIDs) + } + require.LessOrEqual(t, counter.total(), 3, "seen state, page and one aggregate statement") + statementTotals = append(statementTotals, counter.total()) + }) + } + if len(statementTotals) == 2 { + require.Equal(t, statementTotals[0], statementTotals[1], "page size must not change the statement count") + } +} + +func TestNotificationList_UpvoteAggregateSharesPageSnapshot(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + retentionSeenAt(t, f.db, f.recipient, f.sortAt.Add(-time.Second)) + voters := []string{ + "did:plc:listvoter" + testkit.UniqueID(t), + "did:plc:listvoter" + testkit.UniqueID(t), + "did:plc:listvoter" + testkit.UniqueID(t), + } + var newestVote string + for index, voter := range voters { + newestVote = f.listVoteAt(t, voter, f.root, "up", f.sortAt.Add(time.Duration(index)*time.Second)) + } + f.listGroupAt(t, f.root, f.root, f.sortAt.Add(4*time.Second)) + repository, counter := listCountingRepository(t, f.db) + mutated := false + counter.reset(3, func() { + result, err := f.db.ExecContext(context.Background(), `UPDATE votes SET deleted_at = $2 WHERE uri = $1`, newestVote, f.sortAt.Add(5*time.Second)) + require.NoError(t, err) + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows) + mutated = true + }) + first, err := repository.List(context.Background(), f.recipient, "", 10) + require.NoError(t, err) + require.Len(t, first.Notifications, 1, "the qualifying group must be listed") + require.Equal(t, f.root, first.Notifications[0].SubjectURI) + require.True(t, mutated, "retraction must commit before aggregate statement") + require.Equal(t, 3, first.Notifications[0].UpvoteCount) + require.Equal(t, []string{voters[2], voters[1], voters[0]}, first.Notifications[0].RecentUpvoterDIDs) + counter.reset(0, nil) + second, err := repository.List(context.Background(), f.recipient, "", 10) + require.NoError(t, err) + require.Len(t, second.Notifications, 1) + require.Equal(t, 2, second.Notifications[0].UpvoteCount) + require.Equal(t, []string{voters[1], voters[0]}, second.Notifications[0].RecentUpvoterDIDs) +} diff --git a/internal/db/postgres/notification_list_upvote_visibility_test.go b/internal/db/postgres/notification_list_upvote_visibility_test.go new file mode 100644 index 0000000..f8023a8 --- /dev/null +++ b/internal/db/postgres/notification_list_upvote_visibility_test.go @@ -0,0 +1,359 @@ +//go:build integration + +package postgres + +import ( + "context" + "strconv" + "strings" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +// Both vote timestamps increase together; tests do not choose which timestamp +// the read path uses to define the newest voter. +func (f *unreadVisibilityFixture) listVoteAt(t *testing.T, voter, subject, direction string, at time.Time) string { + t.Helper() + key := testkit.TID() + uri := "at://" + voter + "/social.coves.feed.vote/" + key + _, err := f.db.ExecContext(context.Background(), `INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at) + VALUES ($1, 'bafylistvote', $2, $3, $4, 'bafylistvotesubject', $5, $6, $6)`, + uri, key, voter, subject, direction, at.Truncate(time.Microsecond)) + require.NoError(t, err) + return uri +} + +// Upvote rows have no RecordURI; never feed them to recordLabels. +func (f *unreadVisibilityFixture) listRowLabels(t *testing.T, page notifications.ListPage, records, subjects map[string]string) []string { + t.Helper() + got := make([]string, 0, len(page.Notifications)) + for _, row := range page.Notifications { + var label string + var ok bool + if row.Reason == notifications.ReasonUpvote { + label, ok = subjects[row.SubjectURI] + } else { + label, ok = records[row.RecordURI] + } + require.True(t, ok, "unlabeled notification id=%d reason=%s record=%s subject=%s", row.ID, row.Reason, row.RecordURI, row.SubjectURI) + got = append(got, label) + } + return got +} + +func (f *unreadVisibilityFixture) listGroupAt(t *testing.T, subject, root string, at time.Time) { + t.Helper() + f.listNotificationAt(t, f.recipient, "", "upvote", "", subject, root, at) +} + +func TestNotificationList_AgreesWithCountForUpvoteGroups(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, kind string + status posts.AdmissionStatus + listed bool + }{ + {"voter blocked by recipient", "blocked", "", false}, + {"voter blocks recipient", "blocks", "", false}, + {"voter erased", "erased", "", false}, + {"vote retracted", "retracted", "", false}, + {"recipient own vote", "self", "", false}, + {"post pending", "post", posts.AdmissionStatusPending, false}, + {"post rejected", "post", posts.AdmissionStatusRejected, false}, + {"post pending reacceptance", "post", posts.AdmissionStatusPendingReacceptance, false}, + {"post no admission", "post", "", false}, + {"post CID drifted", "drifted", "", false}, + {"post deleted without marker", "deletedPost", posts.AdmissionStatusAccepted, false}, + {"post removed without marker", "post", posts.AdmissionStatusRemoved, false}, + {"post unindexed", "unindexedPost", "", false}, + {"comment pending root", "comment", posts.AdmissionStatusPending, false}, + {"comment unindexed", "unindexedComment", "", false}, + {"post live", "post", posts.AdmissionStatusAccepted, true}, + {"comment live", "comment", posts.AdmissionStatusAccepted, true}, + {"post author deleted with marker", "markedDelete", posts.AdmissionStatusAccepted, true}, + {"post community removed with marker", "markedRemoval", posts.AdmissionStatusRemoved, true}, + {"comment root author deleted with marker", "commentDeletedRoot", posts.AdmissionStatusAccepted, true}, + {"comment root removed with marker", "commentRemovedRoot", posts.AdmissionStatusRemoved, true}, + {"comment subject deleted", "deletedComment", posts.AdmissionStatusAccepted, true}, + {"disabled upvote", "disabled", posts.AdmissionStatusAccepted, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + controlReply := f.listControlRecord(t) + controlPostKey := testkit.TID() + controlPost := seedVisibilityPost(t, f.db, f.community, f.recipient, controlPostKey, "control", f.sortAt) + seedVisibilityAdmission(t, f.db, f.community, controlPost, posts.AdmissionStatusAccepted, "", "") + controlVoter := "did:plc:listvoter" + testkit.UniqueID(t) + f.listVoteAt(t, controlVoter, controlPost, "up", f.sortAt) + f.listGroupAt(t, controlPost, controlPost, f.sortAt.Add(time.Second)) + + subject, root := f.root, f.root + casePostCID, caseCommentCID, caseRootCID := "", "", "" + voter := "did:plc:listvoter" + testkit.UniqueID(t) + if tc.kind == "self" { + voter = f.recipient + } + switch tc.kind { + case "post", "drifted", "deletedPost", "markedDelete", "markedRemoval", "unindexedPost": + if tc.kind == "unindexedPost" { + subject = postV2URI(f.recipient, testkit.TID()) + } else { + key := testkit.TID() + subject = seedVisibilityPost(t, f.db, f.community, f.recipient, key, "case post", f.sortAt) + casePostCID = "bafypostv2" + key + if tc.status != "" { + seedVisibilityAdmission(t, f.db, f.community, subject, tc.status, "", "") + } + } + root = subject + if tc.kind == "drifted" { + seedVisibilityAdmissionDriftedCID(t, f.db, f.community, subject) + } + if tc.kind == "deletedPost" || tc.kind == "markedDelete" { + f.deletePost(t, subject) + } + if tc.kind == "markedDelete" { + seedWithdrawalMarker(t, f.db, subject, "authorDelete", nil) + } + if tc.kind == "markedRemoval" { + seedWithdrawalMarker(t, f.db, subject, "communityWithdrawal", "3lqqqqqqqqqq1") + } + case "comment", "commentDeletedRoot", "commentRemovedRoot", "deletedComment", "unindexedComment": + if tc.kind != "deletedComment" && tc.kind != "unindexedComment" { + key := testkit.TID() + root = seedVisibilityPost(t, f.db, f.community, f.recipient, key, "comment root", f.sortAt) + caseRootCID = "bafypostv2" + key + seedVisibilityAdmission(t, f.db, f.community, root, tc.status, "", "") + } else { + caseRootCID = "bafypostv2" + f.root[strings.LastIndex(f.root, "/")+1:] + } + if tc.kind == "unindexedComment" { + subject = "at://" + f.recipient + "/social.coves.community.comment/" + testkit.TID() + } else { + key := testkit.TID() + subject = seedActorComment(t, f.db, f.recipient, root, key, f.sortAt) + caseCommentCID = "bafycmt" + key + } + if tc.kind == "deletedComment" { + f.deleteComment(t, subject) + } + if tc.kind == "commentDeletedRoot" { + f.deletePost(t, root) + seedWithdrawalMarker(t, f.db, root, "authorDelete", nil) + } + if tc.kind == "commentRemovedRoot" { + seedWithdrawalMarker(t, f.db, root, "communityWithdrawal", "3lqqqqqqqqqq1") + } + } + vote := f.listVoteAt(t, voter, subject, "up", f.sortAt.Add(time.Second)) + switch tc.kind { + case "blocked": + f.insertBlock(t, f.recipient, voter) + case "blocks": + f.insertBlock(t, voter, f.recipient) + case "erased": + f.eraseDID(t, voter) + case "retracted": + _, err := f.db.ExecContext(context.Background(), `UPDATE votes SET deleted_at = $2 WHERE uri = $1`, vote, f.sortAt.Add(2*time.Second)) + require.NoError(t, err) + } + f.listGroupAt(t, subject, root, f.sortAt.Add(2*time.Second)) + // The independent live group is present even when the case group is hidden. + f.requireCount(t, map[bool]int{true: 3, false: 2}[tc.listed]) + page := f.listPage(t, f.recipient, "", 10) + want := []string{"control upvote", "reply"} + if tc.listed { + want = []string{"case", "control upvote", "reply"} + } + require.Equal(t, want, f.listRowLabels(t, page, + map[string]string{controlReply: "reply"}, map[string]string{subject: "case", controlPost: "control upvote"})) + require.Empty(t, page.Cursor) + for _, row := range page.Notifications { + if row.Reason != notifications.ReasonUpvote { + continue + } + require.Empty(t, row.RecordURI) + require.Empty(t, row.ActorDID) + require.True(t, row.RecordCreatedAt.IsZero()) + if row.SubjectURI == controlPost { + require.Equal(t, "bafypostv2"+controlPostKey, row.Subject.CID) + require.Equal(t, "bafypostv2"+controlPostKey, row.RootPost.CID) + require.Equal(t, 1, row.UpvoteCount) + require.Equal(t, []string{controlVoter}, row.RecentUpvoterDIDs) + } else { + require.Equal(t, 1, row.UpvoteCount) + require.Equal(t, []string{voter}, row.RecentUpvoterDIDs) + if casePostCID != "" { + require.Equal(t, casePostCID, row.Subject.CID) + require.Equal(t, casePostCID, row.RootPost.CID) + } + if tc.kind == "comment" || tc.kind == "commentDeletedRoot" || tc.kind == "commentRemovedRoot" || tc.kind == "deletedComment" { + require.NotEqual(t, row.Subject.CID, row.RootPost.CID) + require.Equal(t, caseCommentCID, row.Subject.CID) + require.Equal(t, caseRootCID, row.RootPost.CID) + } + } + } + if tc.kind == "disabled" { + f.setDisabledReasons(t, f.recipient, []string{"upvote"}) + f.requireCount(t, 1) + page = f.listPage(t, f.recipient, "", 10) + require.Equal(t, []string{"reply"}, f.listRowLabels(t, page, map[string]string{controlReply: "reply"}, nil)) + } + }) + } +} + +func TestNotificationList_EveryReasonFillsPagesAndNeverSeenGroup(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := f.sortAt + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Microsecond)) + records, subjects := map[string]string{}, map[string]string{} + for number := 1; number <= 12; number++ { + record, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, base.Add(time.Duration(number)*time.Second), base) + records[record] = "R" + strconv.Itoa(number) + } + postMention := seedVisibilityPost(t, f.db, f.community, f.actor, testkit.TID(), "mention post", base) + seedVisibilityAdmission(t, f.db, f.community, postMention, posts.AdmissionStatusAccepted, "", "") + f.listNotificationAt(t, f.recipient, f.actor, "mention", postMention, "", postMention, base.Add(13*time.Second)) + records[postMention] = "post mention" + commentMention := f.comment(t, f.root) + f.listNotificationAt(t, f.recipient, f.actor, "mention", commentMention, "", f.root, base.Add(14*time.Second)) + records[commentMention] = "comment mention" + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), f.root, "up", base) + f.listGroupAt(t, f.root, f.root, base.Add(15*time.Second)) + subjects[f.root] = "post group" + commentSubject := seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), base) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), commentSubject, "up", base) + f.listGroupAt(t, commentSubject, f.root, base.Add(16*time.Second)) + subjects[commentSubject] = "comment group" + deletedRecord := f.comment(t, f.root) + f.deleteComment(t, deletedRecord) + f.listNotificationAt(t, f.recipient, f.actor, "commentReply", deletedRecord, f.comment(t, f.root), f.root, base.Add(17*time.Second)) + records[deletedRecord] = "deleted record" + deletedSubjectPost := f.post(t, posts.AdmissionStatusAccepted, false) + f.deletePost(t, deletedSubjectPost) + seedWithdrawalMarker(t, f.db, deletedSubjectPost, "authorDelete", nil) + deletedSubjectPostRecord := f.comment(t, f.root) + f.listNotificationAt(t, f.recipient, f.actor, "postReply", deletedSubjectPostRecord, deletedSubjectPost, f.root, base.Add(18*time.Second)) + records[deletedSubjectPostRecord] = "deleted subject post" + deletedSubjectComment := f.comment(t, f.root) + f.deleteComment(t, deletedSubjectComment) + deletedSubjectCommentRecord := f.comment(t, f.root) + f.listNotificationAt(t, f.recipient, f.actor, "commentReply", deletedSubjectCommentRecord, deletedSubjectComment, f.root, base.Add(19*time.Second)) + records[deletedSubjectCommentRecord] = "deleted subject comment" + removedRoot := f.post(t, posts.AdmissionStatusRemoved, false) + seedWithdrawalMarker(t, f.db, removedRoot, "communityWithdrawal", "3lqqqqqqqqqq1") + removedRootRecord := f.comment(t, removedRoot) + f.listNotificationAt(t, f.recipient, f.actor, "commentReply", removedRootRecord, f.comment(t, removedRoot), removedRoot, base.Add(20*time.Second)) + records[removedRootRecord] = "removed root" + deletedRoot := f.post(t, posts.AdmissionStatusAccepted, false) + f.deletePost(t, deletedRoot) + seedWithdrawalMarker(t, f.db, deletedRoot, "authorDelete", nil) + deletedRootRecord := f.comment(t, deletedRoot) + f.listNotificationAt(t, f.recipient, f.actor, "commentReply", deletedRootRecord, f.comment(t, deletedRoot), deletedRoot, base.Add(21*time.Second)) + records[deletedRootRecord] = "deleted root" + deletedGroupComment := seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), base) + f.deleteComment(t, deletedGroupComment) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), deletedGroupComment, "up", base) + f.listGroupAt(t, deletedGroupComment, f.root, base.Add(22*time.Second)) + subjects[deletedGroupComment] = "deleted comment group" + removedGroupPost := f.post(t, posts.AdmissionStatusRemoved, false) + seedWithdrawalMarker(t, f.db, removedGroupPost, "communityWithdrawal", "3lqqqqqqqqqq1") + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), removedGroupPost, "up", base) + f.listGroupAt(t, removedGroupPost, removedGroupPost, base.Add(23*time.Second)) + subjects[removedGroupPost] = "removed post group" + // Hidden rows interleave with visible rows and must consume no page slots. + pending := f.post(t, posts.AdmissionStatusPending, false) + f.listNotificationAt(t, f.recipient, f.actor, "postReply", f.comment(t, f.root), pending, pending, base.Add(18*time.Second+time.Microsecond)) + f.listNotificationAt(t, f.recipient, f.actor, "commentReply", "at://"+f.actor+"/social.coves.community.comment/"+testkit.TID(), f.comment(t, f.root), f.root, base.Add(14*time.Second+time.Microsecond)) + blockedSubject := f.post(t, posts.AdmissionStatusAccepted, false) + blockedVoter := "did:plc:listvoter" + testkit.UniqueID(t) + f.listVoteAt(t, blockedVoter, blockedSubject, "up", base) + f.insertBlock(t, f.recipient, blockedVoter) + f.listGroupAt(t, blockedSubject, blockedSubject, base.Add(20*time.Second+time.Microsecond)) + emptySubject := f.post(t, posts.AdmissionStatusAccepted, false) + f.listGroupAt(t, emptySubject, emptySubject, base.Add(21*time.Second+time.Microsecond)) + f.requireCount(t, 23) + cursor, got := "", []string{} + for _, size := range []int{7, 5, 7, 4} { + page := f.listPage(t, f.recipient, cursor, size) + if cursor == "" { + require.Equal(t, []string{"removed post group", "deleted comment group", "deleted root", "removed root", "deleted subject comment", "deleted subject post", "deleted record"}, f.listRowLabels(t, page, records, subjects)) + } + require.Len(t, page.Notifications, size) + got = append(got, f.listRowLabels(t, page, records, subjects)...) + if size == 4 { + require.Empty(t, page.Cursor) + } else { + require.NotEmpty(t, page.Cursor) + } + cursor = page.Cursor + } + require.Equal(t, []string{"removed post group", "deleted comment group", "deleted root", "removed root", "deleted subject comment", "deleted subject post", "deleted record", "comment group", "post group", "comment mention", "post mention", "R12", "R11", "R10", "R9", "R8", "R7", "R6", "R5", "R4", "R3", "R2", "R1"}, got) + _, clearSeenErr := f.db.Exec(`UPDATE notification_state SET seen_at = NULL WHERE did = $1`, f.recipient) + require.NoError(t, clearSeenErr) + newestSubject := f.post(t, posts.AdmissionStatusAccepted, false) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), newestSubject, "up", base) + f.listGroupAt(t, newestSubject, newestSubject, base.Add(24*time.Second)) + subjects[newestSubject] = "newest group" + f.listNotificationAt(t, f.recipient, f.actor, "postReply", f.comment(t, f.root), pending, pending, base.Add(25*time.Second)) + f.requireCount(t, 1) + page := f.listPage(t, f.recipient, "", 100) + require.Equal(t, "newest group", f.listRowLabels(t, page, records, subjects)[0]) + unread := []string{} + for _, row := range page.Notifications { + if !row.IsRead { + unread = append(unread, subjects[row.SubjectURI]) + } + } + require.Equal(t, []string{"newest group"}, unread) +} + +func TestNotificationList_BumpedGroupMovesAboveOldCursor(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + var now time.Time + require.NoError(t, f.db.QueryRowContext(context.Background(), `SELECT now()`).Scan(&now)) + base := now.UTC().Truncate(time.Microsecond).Add(-time.Hour) + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Second)) + records, subjects := map[string]string{}, map[string]string{} + for i := 1; i <= 4; i++ { + record, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, base.Add(time.Duration(i*2)*time.Second), base) + records[record] = "R" + strconv.Itoa(i) + } + old := f.post(t, posts.AdmissionStatusAccepted, false) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), old, "up", base) + f.listGroupAt(t, old, old, base.Add(time.Second)) + subjects[old] = "moved" + other := f.post(t, posts.AdmissionStatusAccepted, false) + f.listVoteAt(t, "did:plc:listvoter"+testkit.UniqueID(t), other, "up", base) + f.listGroupAt(t, other, other, base.Add(3*time.Second)) + subjects[other] = "other group" + first := f.listPage(t, f.recipient, "", 2) + require.Equal(t, []string{"R4", "R3"}, f.listRowLabels(t, first, records, subjects)) + require.NotEmpty(t, first.Cursor) + tx, err := f.db.BeginTx(context.Background(), nil) + require.NoError(t, err) + defer tx.Rollback() + require.NoError(t, NewNotificationRepository(f.db).ApplyUpvoteGroupTx(context.Background(), tx, notifications.UpvoteGroupIntent{ + Action: notifications.UpvoteGroupBump, RecipientDID: f.recipient, SubjectURI: old, RootPostURI: old, + })) + require.NoError(t, tx.Commit()) + second := f.listPage(t, f.recipient, first.Cursor, 2) + require.Equal(t, []string{"R2", "other group"}, f.listRowLabels(t, second, records, subjects)) + require.NotEmpty(t, second.Cursor) + third := f.listPage(t, f.recipient, second.Cursor, 2) + require.Equal(t, []string{"R1"}, f.listRowLabels(t, third, records, subjects)) + require.Empty(t, third.Cursor) + fresh := f.listPage(t, f.recipient, "", 3) + require.Equal(t, []string{"moved", "R4", "R3"}, f.listRowLabels(t, fresh, records, subjects)) +} diff --git a/internal/db/postgres/notification_list_upvoter_order_test.go b/internal/db/postgres/notification_list_upvoter_order_test.go new file mode 100644 index 0000000..657c068 --- /dev/null +++ b/internal/db/postgres/notification_list_upvoter_order_test.go @@ -0,0 +1,75 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationList_RecentUpvotersIndexTimeAndHigherIDTieBreak(t *testing.T) { + type indexedVote struct { + name string + indexedAgo time.Duration + createdAgo time.Duration + } + for _, tc := range []struct { + name string + votes []indexedVote + want []string + }{ + { + name: "equal indexed time uses later insert even when createdAt is older", + votes: []indexedVote{{"V4", time.Minute, time.Second}, {"V5", time.Minute, time.Hour}}, + want: []string{"V5", "V4"}, + }, + { + name: "five votes and third-place indexed tie", + votes: []indexedVote{ + {"V1", time.Minute, 5 * time.Minute}, + {"V2", 2 * time.Minute, 4 * time.Minute}, + {"V3", 3 * time.Minute, 2 * time.Minute}, + {"V4", 3 * time.Minute, 3 * time.Minute}, + {"V5", 4 * time.Minute, time.Minute}, + }, + want: []string{"V1", "V2", "V4"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadCountFixture(t) + ctx := context.Background() + var now time.Time + require.NoError(t, f.db.QueryRowContext(ctx, `SELECT now()`).Scan(&now)) + now = now.UTC().Truncate(time.Microsecond) + f.listGroupAt(t, f.root, f.root, now) + dids := make(map[string]string) + for _, vote := range tc.votes { + voter := "did:plc:listorder" + testkit.UniqueID(t) + dids[vote.name] = voter + key := testkit.TID() + _, err := f.db.ExecContext(ctx, `INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at) + VALUES ($1, 'bafylistvote', $2, $3, $4, 'bafylistvotesubject', 'up', $5, $6)`, + "at://"+voter+"/social.coves.feed.vote/"+key, key, voter, f.root, + now.Add(-vote.createdAgo), now.Add(-vote.indexedAgo)) + require.NoError(t, err) + } + want := make([]string, 0, len(tc.want)) + for _, name := range tc.want { + want = append(want, dids[name]) + } + for read := 0; read < 3; read++ { + page := f.listPage(t, f.recipient, "", 10) + require.Len(t, page.Notifications, 1) + require.Equal(t, f.root, page.Notifications[0].SubjectURI) + require.Equal(t, len(tc.votes), page.Notifications[0].UpvoteCount) + require.Equal(t, want, page.Notifications[0].RecentUpvoterDIDs, "List call %d", read+1) + } + }) + } +} diff --git a/internal/db/postgres/notification_list_visibility_test.go b/internal/db/postgres/notification_list_visibility_test.go new file mode 100644 index 0000000..aa6f2f6 --- /dev/null +++ b/internal/db/postgres/notification_list_visibility_test.go @@ -0,0 +1,389 @@ +//go:build integration + +package postgres + +import ( + "context" + "encoding/json" + "strconv" + "testing" + "time" + + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func (f *unreadVisibilityFixture) listControlRecord(t *testing.T) string { + t.Helper() + var record string + require.NoError(t, f.db.QueryRowContext(context.Background(), + `SELECT record_uri FROM notifications WHERE recipient_did = $1`, f.recipient).Scan(&record)) + return record +} + +// Insert a notification at an explicit time, preserving the fixture's time for +// seeding its referenced posts and comments. Upvote groups have no record/actor. +func (f *unreadVisibilityFixture) listNotificationAt(t *testing.T, recipient, actor, reason, record, subject, root string, at time.Time) { + t.Helper() + var recordURI, recordCID, actorDID, subjectURI, createdAt any = record, "bafyunreadrecord", actor, subject, at + if reason == "mention" { + subjectURI = nil + } + if reason == "upvote" { + recordURI, recordCID, actorDID, createdAt = nil, nil, nil, nil + } + _, err := f.db.ExecContext(context.Background(), `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + recipient, reason, recordURI, recordCID, actorDID, subjectURI, root, createdAt, at.Truncate(time.Microsecond)) + require.NoError(t, err) +} + +func TestNotificationList_AgreesWithCountForReplyRows(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, kind, position string + status posts.AdmissionStatus + legacy, drifted bool + wantLabels []string + wantCount int + }{ + {"subject pending", "post", "subject", posts.AdmissionStatusPending, false, false, []string{"control"}, 1}, + {"subject rejected", "post", "subject", posts.AdmissionStatusRejected, false, false, []string{"control"}, 1}, + {"subject pending reacceptance", "post", "subject", posts.AdmissionStatusPendingReacceptance, false, false, []string{"control"}, 1}, + {"subject no admission", "post", "subject", "", false, false, []string{"control"}, 1}, + {"subject CID drifted", "post", "subject", "", false, true, []string{"control"}, 1}, + {"subject accepted current CID", "post", "subject", posts.AdmissionStatusAccepted, false, false, []string{"case", "control"}, 2}, + {"root pending", "post", "root", posts.AdmissionStatusPending, false, false, []string{"control"}, 1}, + {"root accepted", "post", "root", posts.AdmissionStatusAccepted, false, false, []string{"case", "control"}, 2}, + {"legacy root live", "post", "root", "", true, false, []string{"case", "control"}, 2}, + {"subject deleted without marker", "deletedPost", "subject", posts.AdmissionStatusAccepted, false, false, []string{"control"}, 1}, + {"subject removed without marker", "post", "subject", posts.AdmissionStatusRemoved, false, false, []string{"control"}, 1}, + {"legacy root deleted without marker", "deletedPost", "root", "", true, false, []string{"control"}, 1}, + {"author-deleted subject accepted with marker", "deletedPostMarked", "subject", posts.AdmissionStatusAccepted, false, false, []string{"case", "control"}, 2}, + {"author-deleted subject admission withdrawn with marker", "deletedPostMarked", "subject", "", false, false, []string{"case", "control"}, 2}, + {"author-deleted root with marker", "deletedPostMarked", "root", posts.AdmissionStatusAccepted, false, false, []string{"case", "control"}, 2}, + {"community-removed subject with marker", "removedPostMarked", "subject", posts.AdmissionStatusRemoved, false, false, []string{"case", "control"}, 2}, + {"community-removed root with marker", "removedPostMarked", "root", posts.AdmissionStatusRemoved, false, false, []string{"case", "control"}, 2}, + {"deleted record comment", "deletedRecord", "record", "", false, false, []string{"case", "control"}, 2}, + {"deleted subject comment", "deletedSubject", "subject", "", false, false, []string{"case", "control"}, 2}, + {"community withdrawal lifted with stale marker", "liftedRemoval", "subject", posts.AdmissionStatusPending, false, false, []string{"control"}, 1}, + {"recipient blocks placeholder actor", "placeholderBlock", "recipient", "", false, false, []string{"control"}, 1}, + {"placeholder actor blocks recipient", "placeholderBlock", "actor", "", false, false, []string{"control"}, 1}, + {"disabled placeholder postReply", "placeholderDisabled", "postReply", "", false, false, []string{"control"}, 1}, + {"unindexed subject post", "unindexed", "subjectPost", "", false, false, []string{"control"}, 1}, + {"unindexed root post", "unindexed", "rootPost", "", false, false, []string{"control"}, 1}, + {"unindexed record comment", "unindexed", "recordComment", "", false, false, []string{"control"}, 1}, + {"unindexed subject comment", "unindexed", "subjectComment", "", false, false, []string{"control"}, 1}, + {"recipient blocks actor", "block", "recipient", "", false, false, []string{"control"}, 1}, + {"actor blocks recipient", "block", "actor", "", false, false, []string{"control"}, 1}, + {"disabled postReply", "disabled", "postReply", "", false, false, []string{"control"}, 1}, + {"disabled commentReply", "disabled", "commentReply", "", false, false, []string{"post control"}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + control := f.listControlRecord(t) + labels := map[string]string{control: "control"} + actor := f.actor + if tc.kind == "block" || tc.kind == "placeholderBlock" || tc.position == "commentReply" { + actor = "did:plc:listcase" + testkit.UniqueID(t) + } + record := seedActorComment(t, f.db, actor, f.root, testkit.TID(), f.sortAt) + subject, root, reason := f.root, f.root, "postReply" + switch tc.kind { + case "post", "deletedPost", "deletedPostMarked", "removedPostMarked", "liftedRemoval": + post := f.post(t, tc.status, tc.legacy) + if tc.drifted { + seedVisibilityAdmissionDriftedCID(t, f.db, f.community, post) + } + if tc.kind == "deletedPost" || tc.kind == "deletedPostMarked" { + f.deletePost(t, post) + } + if tc.kind == "deletedPostMarked" { + seedWithdrawalMarker(t, f.db, post, "authorDelete", nil) + } + if tc.kind == "removedPostMarked" || tc.kind == "liftedRemoval" { + seedWithdrawalMarker(t, f.db, post, "communityWithdrawal", "3lqqqqqqqqqq1") + } + if tc.position == "subject" { + subject = post + } else { + root, subject, reason = post, f.comment(t, post), "commentReply" + } + case "unindexed": + switch tc.position { + case "subjectPost": + subject = postV2URI(f.recipient, testkit.TID()) + case "rootPost": + root = postV2URI(f.recipient, testkit.TID()) + case "recordComment": + record = "at://" + actor + "/social.coves.community.comment/" + testkit.TID() + case "subjectComment": + subject = "at://" + f.recipient + "/social.coves.community.comment/" + testkit.TID() + } + if tc.position == "subjectComment" || tc.position == "recordComment" || tc.position == "rootPost" { + reason = "commentReply" + if tc.position != "subjectComment" { + subject = f.comment(t, f.root) + } + } + case "block": + if tc.position == "recipient" { + f.insertBlock(t, f.recipient, actor) + } else { + f.insertBlock(t, actor, f.recipient) + } + case "placeholderBlock": + f.deleteComment(t, record) + if tc.position == "recipient" { + f.insertBlock(t, f.recipient, actor) + } else { + f.insertBlock(t, actor, f.recipient) + } + case "placeholderDisabled": + f.deleteComment(t, record) + f.setDisabledReasons(t, f.recipient, []string{tc.position}) + case "deletedRecord": + f.deleteComment(t, record) + case "deletedSubject": + reason = "commentReply" + subject = f.comment(t, f.root) + f.deleteComment(t, subject) + case "disabled": + if tc.position == "commentReply" { + reason = "commentReply" + subject = f.comment(t, f.root) + postControl, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, f.sortAt.Add(2*time.Second), f.sortAt) + labels[postControl] = "post control" + } + f.setDisabledReasons(t, f.recipient, []string{tc.position}) + } + f.listNotificationAt(t, f.recipient, actor, reason, record, subject, root, f.sortAt.Add(time.Second)) + labels[record] = "case" + f.requireCount(t, tc.wantCount) + page := f.listPage(t, f.recipient, "", 10) + require.Equal(t, tc.wantLabels, f.recordLabels(t, page, labels)) + require.Empty(t, page.Cursor) + }) + } +} + +func TestNotificationList_NeverSeenUnreadIsNewestVisibleRow(t *testing.T) { + t.Parallel() + for _, scenario := range []string{"newest hidden", "newest upvote with null state", "newest placeholder", "tied replies across pages", "foreign newer reply"} { + t.Run(scenario, func(t *testing.T) { + f := newUnreadCountFixture(t) + base := f.sortAt + labels := make(map[string]string) + add := func(label string, at time.Time) { + record, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, at, base) + labels[record] = label + } + add("R1", base) + switch scenario { + case "newest hidden": + add("R2", base.Add(time.Second)) + missingRoot := postV2URI(f.recipient, testkit.TID()) + f.listNotificationAt(t, f.recipient, f.actor, "postReply", f.comment(t, f.root), missingRoot, missingRoot, base.Add(2*time.Second)) + case "newest upvote with null state": + retentionSeenAt(t, f.db, f.recipient, nil) + f.insertVote(t, "did:plc:listvoter"+testkit.UniqueID(t), f.root, false) + f.listNotificationAt(t, f.recipient, "", "upvote", "", f.root, f.root, base.Add(time.Second)) + case "newest placeholder": + record := f.comment(t, f.root) + f.deleteComment(t, record) + labels[record] = "P" + f.listNotificationAt(t, f.recipient, f.actor, "commentReply", record, f.comment(t, f.root), f.root, base.Add(time.Second)) + case "tied replies across pages": + add("R2a", base.Add(time.Second)) + add("R2b", base.Add(time.Second)) + case "foreign newer reply": + other := retentionUser(t, f.db) + f.insertListedReply(t, other, "postReply", f.root, base.Add(time.Second), base) + } + f.requireCount(t, 1) + if scenario == "tied replies across pages" { + cursor := "" + for _, want := range []struct { + label, next string + isRead bool + }{{"R2b", "more", false}, {"R2a", "more", true}, {"R1", "end", true}} { + page := f.listPage(t, f.recipient, cursor, 1) + require.Equal(t, []string{want.label}, f.recordLabels(t, page, labels)) + require.Equal(t, want.isRead, page.Notifications[0].IsRead) + require.Nil(t, page.SeenAt) + if want.next == "more" { + require.NotEmpty(t, page.Cursor) + } else { + require.Empty(t, page.Cursor) + } + cursor = page.Cursor + } + return + } + page := f.listPage(t, f.recipient, "", 10) + switch scenario { + case "newest upvote with null state": + require.Len(t, page.Notifications, 2) + require.Equal(t, "upvote", string(page.Notifications[0].Reason)) + require.Equal(t, f.root, page.Notifications[0].SubjectURI) + require.False(t, page.Notifications[0].IsRead) + require.Equal(t, "R1", labels[page.Notifications[1].RecordURI]) + require.True(t, page.Notifications[1].IsRead) + case "newest placeholder": + require.Equal(t, []string{"P", "R1"}, f.recordLabels(t, page, labels)) + require.Equal(t, []bool{false, true}, []bool{page.Notifications[0].IsRead, page.Notifications[1].IsRead}) + case "newest hidden": + require.Equal(t, []string{"R2", "R1"}, f.recordLabels(t, page, labels)) + require.Equal(t, []bool{false, true}, []bool{page.Notifications[0].IsRead, page.Notifications[1].IsRead}) + case "foreign newer reply": + require.Equal(t, []string{"R1"}, f.recordLabels(t, page, labels)) + require.False(t, page.Notifications[0].IsRead) + default: + require.Equal(t, []string{"R1"}, f.recordLabels(t, page, labels)) + require.True(t, page.Notifications[0].IsRead) + } + require.Nil(t, page.SeenAt) + require.Empty(t, page.Cursor) + }) + } +} + +func TestNotificationList_FullPageThroughHiddenRows(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := f.sortAt + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Microsecond)) + pendingRoot := f.post(t, posts.AdmissionStatusPending, false) + labels := make(map[string]string) + for number := 1; number <= 60; number++ { + at := base.Add(time.Duration(number) * time.Microsecond) + switch number % 3 { + case 1: + record, _ := f.insertListedReply(t, f.recipient, "postReply", f.root, at, base) + labels[record] = "V" + strconv.Itoa((number+2)/3) + case 2: + record := f.comment(t, f.root) + f.listNotificationAt(t, f.recipient, f.actor, "postReply", record, pendingRoot, pendingRoot, at) + case 0: + missing := "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + f.listNotificationAt(t, f.recipient, f.actor, "commentReply", missing, f.comment(t, f.root), f.root, at) + } + } + page := f.listPage(t, f.recipient, "", 20) + require.Equal(t, []string{ + "V20", "V19", "V18", "V17", "V16", "V15", "V14", "V13", "V12", "V11", + "V10", "V9", "V8", "V7", "V6", "V5", "V4", "V3", "V2", "V1", + }, f.recordLabels(t, page, labels)) + require.Empty(t, page.Cursor) +} + +// Subplans used to decide read state or visibility do not satisfy the outer +// recipient-page access-path contract and cannot introduce an outer sort. +func listMainPlanAccess(node map[string]any) (index, sort bool) { + if node["Index Name"] == "idx_notifications_recipient_sort" && + (node["Node Type"] == "Index Scan" || node["Node Type"] == "Index Only Scan") { + index = true + } + if node["Node Type"] == "Sort" || node["Node Type"] == "Incremental Sort" { + sort = true + } + children, _ := node["Plans"].([]any) + for _, child := range children { + plan, ok := child.(map[string]any) + if !ok || plan["Parent Relationship"] == "InitPlan" || plan["Parent Relationship"] == "SubPlan" { + continue + } + childIndex, childSort := listMainPlanAccess(plan) + index, sort = index || childIndex, sort || childSort + } + return index, sort +} + +func TestNotificationList_RecipientSortIndexWithMostlyBlocked(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := f.sortAt + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Second)) + blocked := "did:plc:listblocked" + testkit.UniqueID(t) + f.insertBlock(t, f.recipient, blocked) + prefix := "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + "-" + _, err := f.db.ExecContext(context.Background(), ` + WITH records AS MATERIALIZED ( + SELECT number, $2::text || number::text AS uri, + CASE WHEN number % 10 = 0 THEN $3::text ELSE $4::text END AS actor, + $6::timestamptz + number * interval '1 microsecond' AS sort_at + FROM generate_series(1, 10000) AS number + ), inserted AS ( + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + SELECT uri, 'bafylistcomment', number::text, actor, $5, 'bafylistroot', $5, 'bafylistroot', 'list comment', sort_at + FROM records RETURNING uri + ) + INSERT INTO notifications (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + SELECT $1, 'postReply', records.uri, 'bafylistcomment', records.actor, $5, $5, records.sort_at, records.sort_at + FROM records JOIN inserted ON inserted.uri = records.uri`, + f.recipient, prefix, f.actor, blocked, f.root, base) + require.NoError(t, err) + + otherPrefix := "did:plc:listother" + testkit.UniqueID(t) + _, err = f.db.ExecContext(context.Background(), `INSERT INTO users (did, handle, pds_url) + SELECT $1 || number::text, $2 || number::text || '.test', 'https://pds.test' + FROM generate_series(1, 20) AS number`, otherPrefix, testkit.UniqueID(t)) + require.NoError(t, err) + _, err = f.db.ExecContext(context.Background(), ` + WITH records AS MATERIALIZED ( + SELECT number, $1::text || number::text AS uri, + $2::text || (((number - 1) % 20) + 1)::text AS recipient, + $4::timestamptz + number * interval '1 microsecond' AS sort_at + FROM generate_series(1, 10000) AS number + ), inserted AS ( + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + SELECT uri, 'bafylistcomment', number::text, $3, $5, 'bafylistroot', $5, 'bafylistroot', 'list comment', sort_at + FROM records RETURNING uri + ) + INSERT INTO notifications (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + SELECT records.recipient, 'postReply', records.uri, 'bafylistcomment', $3, $5, $5, records.sort_at, records.sort_at + FROM records JOIN inserted ON inserted.uri = records.uri`, prefix+"other-", otherPrefix, f.actor, base.Add(time.Second), f.root) + require.NoError(t, err) + + for _, table := range []string{"notifications", "notification_state", "posts", "community_post_admissions", "comments", "notification_public_post_withdrawals", "user_blocks", "votes"} { + _, err := f.db.ExecContext(context.Background(), "ANALYZE "+table) + require.NoError(t, err, "analyzing %s", table) + } + var midID int64 + require.NoError(t, f.db.QueryRowContext(context.Background(), `SELECT id FROM notifications WHERE record_uri = $1`, prefix+"5000").Scan(&midID)) + for _, tc := range []struct { + name string + sortAt any + id any + }{ + {"first page", nil, nil}, + {"mid-history cursor", base.Add(5000 * time.Microsecond), midID}, + } { + t.Run(tc.name, func(t *testing.T) { + var raw []byte + require.NoError(t, f.db.QueryRowContext(context.Background(), "EXPLAIN (FORMAT JSON) "+listNotificationsSQL, + f.recipient, tc.sortAt, tc.id, 51).Scan(&raw)) + var plans []struct { + Plan map[string]any `json:"Plan"` + } + require.NoError(t, json.Unmarshal(raw, &plans)) + require.Len(t, plans, 1) + index, sort := listMainPlanAccess(plans[0].Plan) + require.True(t, index, "outer path must scan idx_notifications_recipient_sort: %s", raw) + require.False(t, sort, "outer path must not sort: %s", raw) + }) + } + labels := make(map[string]string) + want := make([]string, 0, 50) + for number := 10000; number >= 9510; number -= 10 { + label := "V" + strconv.Itoa(number) + labels[prefix+strconv.Itoa(number)] = label + want = append(want, label) + } + page := f.listPage(t, f.recipient, "", 50) + require.Equal(t, want, f.recordLabels(t, page, labels)) + require.NotEmpty(t, page.Cursor) +} diff --git a/internal/db/postgres/notification_migration_test.go b/internal/db/postgres/notification_migration_test.go new file mode 100644 index 0000000..22177be --- /dev/null +++ b/internal/db/postgres/notification_migration_test.go @@ -0,0 +1,207 @@ +//go:build integration + +package postgres + +import ( + "Coves/tests/testkit" + "context" + "database/sql" + "testing" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func requireNotificationSQLState(t *testing.T, err error, code string) *pq.Error { + t.Helper() + require.Error(t, err) + var databaseError *pq.Error + require.ErrorAs(t, err, &databaseError) + require.Equal(t, pq.ErrorCode(code), databaseError.Code) + return databaseError +} + +type migrationNotification struct { + recipientDID string + reason string + recordURI any + recordCID any + actorDID any + subjectURI any + rootPostURI string +} + +func insertMigrationNotification(db *sql.DB, notification migrationNotification) error { + _, err := db.Exec(`INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, notification.recipientDID, notification.reason, + notification.recordURI, notification.recordCID, notification.actorDID, + notification.subjectURI, notification.rootPostURI) + return err +} + +func TestNotificationMigration053_ActivationSingleton(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM notification_activation`).Scan(&count)) + require.Equal(t, 1, count, "a migrated clone starts with one activation row") + + _, err := db.Exec(`INSERT INTO notification_activation (activated_at) VALUES (NOW())`) + requireNotificationSQLState(t, err, "23505") + _, err = db.Exec(`INSERT INTO notification_activation (singleton, activated_at) VALUES (false, NOW())`) + requireNotificationSQLState(t, err, "23514") +} + +func TestNotificationMigration053_DownAndUpRoundTrip(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing 053's Down section") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "this must exercise 053's Down section, not an earlier migration") + for _, table := range []string{"notifications", "notification_state", "notification_activation"} { + var relation sql.NullString + require.NoError(t, db.QueryRow(`SELECT to_regclass($1)`, table).Scan(&relation)) + require.Falsef(t, relation.Valid, "%s must be gone after 053 Down", table) + } + testkit.MigrateUp(t, db) + for _, table := range []string{"notifications", "notification_state", "notification_activation"} { + var relation sql.NullString + require.NoError(t, db.QueryRow(`SELECT to_regclass($1)`, table).Scan(&relation)) + require.Truef(t, relation.Valid, "%s must be recreated by 053 Up", table) + } + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM notification_activation`).Scan(&count)) + require.Equal(t, 1, count, "053 Up must seed exactly one activation row again") +} + +func TestNotificationMigration055_DownAndUpRoundTrip(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "this must exercise 055's Down section, not an earlier migration") + var relation sql.NullString + require.NoError(t, db.QueryRow(`SELECT to_regclass('notification_public_post_withdrawals')`).Scan(&relation)) + require.False(t, relation.Valid, "055 Down must drop notification_public_post_withdrawals") + + testkit.MigrateUp(t, db) + require.NoError(t, db.QueryRow(`SELECT to_regclass('notification_public_post_withdrawals')`).Scan(&relation)) + require.True(t, relation.Valid, "055 Up must recreate notification_public_post_withdrawals") + + postURI := "at://did:plc:withdrawalmigration/social.coves.community.postv2/one" + _, err := db.Exec(`INSERT INTO notification_public_post_withdrawals (post_uri, kind, community_rev) + VALUES ($1, 'authorDelete', NULL)`, postURI) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO notification_public_post_withdrawals (post_uri, kind, community_rev) + VALUES ($1, 'communityWithdrawal', 'rev-one')`, postURI) + require.NoError(t, err, "both withdrawal kinds must coexist for the same post URI") + + for _, invalid := range []struct { + name string + kind string + communityRev any + }{ + {"unknown kind", "other", nil}, + {"author delete with rev", "authorDelete", "rev-two"}, + {"community withdrawal without rev", "communityWithdrawal", nil}, + } { + t.Run(invalid.name, func(t *testing.T) { + _, err := db.Exec(`INSERT INTO notification_public_post_withdrawals (post_uri, kind, community_rev) + VALUES ($1, $2, $3)`, postURI, invalid.kind, invalid.communityRev) + requireNotificationSQLState(t, err, "23514") + }) + } + _, err = db.Exec(`INSERT INTO notification_public_post_withdrawals (post_uri, kind, community_rev) + VALUES ($1, 'authorDelete', NULL)`, postURI) + requireNotificationSQLState(t, err, "23505") +} + +func TestNotificationMigration053_ReasonShapeChecks(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + recipientDID := "did:plc:notificationcheck" + testkit.UniqueID(t) + createTestUser(t, db, "notificationcheck"+testkit.UniqueID(t)+".test", recipientDID) + actorDID := "did:plc:notificationactor" + testkit.UniqueID(t) + rootPostURI := "at://" + recipientDID + "/social.coves.community.post/root" + valid := []migrationNotification{ + {recipientDID, "postReply", "at://" + actorDID + "/social.coves.community.comment/postreply", "bafyreply", actorDID, rootPostURI, rootPostURI}, + {recipientDID, "commentReply", "at://" + actorDID + "/social.coves.community.comment/commentreply", "bafyreply", actorDID, rootPostURI, rootPostURI}, + {recipientDID, "mention", "at://" + actorDID + "/social.coves.community.comment/mention", "bafymention", actorDID, nil, rootPostURI}, + {recipientDID, "upvote", nil, nil, nil, rootPostURI, rootPostURI}, + } + for _, notification := range valid { + require.NoErrorf(t, insertMigrationNotification(db, notification), "%s valid shape", notification.reason) + } + + for _, example := range []struct { + name string + valid migrationNotification + change func(*migrationNotification) + }{ + {"upvote with actor", valid[3], func(row *migrationNotification) { row.actorDID = actorDID }}, + {"mention with subject", valid[2], func(row *migrationNotification) { row.subjectURI = rootPostURI }}, + {"postReply without CID", valid[0], func(row *migrationNotification) { row.recordCID = nil }}, + {"commentReply without CID", valid[1], func(row *migrationNotification) { row.recordCID = nil }}, + } { + t.Run(example.name, func(t *testing.T) { + invalid := example.valid + example.change(&invalid) + requireNotificationSQLState(t, insertMigrationNotification(db, invalid), "23514") + }) + } +} + +func TestNotificationMigration053_UniqueIndexes(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + suffix := testkit.UniqueID(t) + recipientDID := "did:plc:notificationunique" + suffix + otherRecipientDID := "did:plc:notificationother" + suffix + createTestUser(t, db, "notificationunique"+suffix+".test", recipientDID) + createTestUser(t, db, "notificationother"+suffix+".test", otherRecipientDID) + rootPostURI := "at://" + recipientDID + "/social.coves.community.post/root" + reply := migrationNotification{ + recipientDID, "postReply", "at://did:plc:replyauthor/social.coves.community.comment/reply", "bafyreply", + "did:plc:replyauthor", rootPostURI, rootPostURI, + } + require.NoError(t, insertMigrationNotification(db, reply)) + require.Equal(t, "uq_notifications_record", + requireNotificationSQLState(t, insertMigrationNotification(db, reply), "23505").Constraint) + + differentReason := reply + differentReason.reason = "commentReply" + require.NoError(t, insertMigrationNotification(db, differentReason), "record URI is reusable under another reason") + differentRecipient := reply + differentRecipient.recipientDID = otherRecipientDID + require.NoError(t, insertMigrationNotification(db, differentRecipient), "record URI and reason are reusable for another recipient") + + group := migrationNotification{recipientDID, "upvote", nil, nil, nil, rootPostURI, rootPostURI} + require.NoError(t, insertMigrationNotification(db, group)) + require.Equal(t, "uq_notifications_upvote_group", + requireNotificationSQLState(t, insertMigrationNotification(db, group), "23505").Constraint) +} + +func TestNotificationMigration053_RecipientForeignKeys(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + missingDID := "did:plc:notificationmissing" + testkit.UniqueID(t) + rootPostURI := "at://" + missingDID + "/social.coves.community.post/root" + requireNotificationSQLState(t, insertMigrationNotification(db, + migrationNotification{missingDID, "upvote", nil, nil, nil, rootPostURI, rootPostURI}), "23503") + _, err := db.Exec(`INSERT INTO notification_state (did) VALUES ($1)`, missingDID) + requireNotificationSQLState(t, err, "23503") +} + +func TestNotificationMigration053_ErasedRecipientCannotReceiveNotifications(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + recipientDID := "did:plc:notificationerased" + testkit.UniqueID(t) + createTestUser(t, db, "notificationerased"+testkit.UniqueID(t)+".test", recipientDID) + require.NoError(t, NewUserRepository(db).Delete(context.Background(), recipientDID)) + rootPostURI := "at://" + recipientDID + "/social.coves.community.post/root" + requireNotificationSQLState(t, insertMigrationNotification(db, + migrationNotification{recipientDID, "upvote", nil, nil, nil, rootPostURI, rootPostURI}), "23503") +} diff --git a/internal/db/postgres/notification_preferences.go b/internal/db/postgres/notification_preferences.go new file mode 100644 index 0000000..155a3ba --- /dev/null +++ b/internal/db/postgres/notification_preferences.go @@ -0,0 +1,81 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "Coves/internal/core/notifications" + + "github.com/lib/pq" +) + +var _ notifications.PreferencesRepository = (*postgresNotificationRepo)(nil) + +const preferenceColumnsSQL = `NOT COALESCE('postReply' = ANY(disabled_reasons), false), + NOT COALESCE('commentReply' = ANY(disabled_reasons), false), + NOT COALESCE('mention' = ANY(disabled_reasons), false), + NOT COALESCE('upvote' = ANY(disabled_reasons), false)` + +// GetPreferences defaults to every reason enabled when the recipient has no state. +func (r *postgresNotificationRepo) GetPreferences(ctx context.Context, did string) (notifications.Preferences, error) { + var preferences notifications.Preferences + err := r.db.QueryRowContext(ctx, `SELECT `+preferenceColumnsSQL+` FROM notification_state WHERE did = $1`, did). + Scan(&preferences.PostReply, &preferences.CommentReply, &preferences.Mention, &preferences.Upvote) + if errors.Is(err, sql.ErrNoRows) { + return notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: true}, nil + } + if err != nil { + return notifications.Preferences{}, fmt.Errorf("get notification preferences: %w", err) + } + return preferences, nil +} + +// PutPreferences changes only the named reasons without advancing seen_at. +func (r *postgresNotificationRepo) PutPreferences(ctx context.Context, did string, update notifications.PreferencesUpdate) (notifications.Preferences, error) { + additions := []string{} + removals := []string{} + for _, change := range []struct { + reason string + enabled *bool + }{ + {"postReply", update.PostReply}, + {"commentReply", update.CommentReply}, + {"mention", update.Mention}, + {"upvote", update.Upvote}, + } { + if change.enabled == nil { + continue + } + if *change.enabled { + removals = append(removals, change.reason) + } else { + additions = append(additions, change.reason) + } + } + + var preferences notifications.Preferences + err := r.db.QueryRowContext(ctx, `INSERT INTO notification_state (did, disabled_reasons) + VALUES ($1, $2::text[]) + ON CONFLICT (did) DO UPDATE SET disabled_reasons = ARRAY( + SELECT DISTINCT reason FROM unnest(notification_state.disabled_reasons || EXCLUDED.disabled_reasons) AS reason + WHERE reason IS NOT NULL AND reason <> ALL($3::text[])) + RETURNING `+preferenceColumnsSQL, did, pq.Array(additions), pq.Array(removals)). + Scan(&preferences.PostReply, &preferences.CommentReply, &preferences.Mention, &preferences.Upvote) + if isStateAccountForeignKeyViolation(err) { + return notifications.Preferences{}, notifications.ErrAccountNotIndexed + } + if err != nil { + return notifications.Preferences{}, fmt.Errorf("put notification preferences: %w", err) + } + return preferences, nil +} + +// isStateAccountForeignKeyViolation reports whether err is the foreign-key +// violation raised when notification_state's did has no users row. +func isStateAccountForeignKeyViolation(err error) bool { + var databaseError *pq.Error + return errors.As(err, &databaseError) && databaseError.Code == "23503" && + databaseError.Constraint == "notification_state_did_fkey" +} diff --git a/internal/db/postgres/notification_preferences_test.go b/internal/db/postgres/notification_preferences_test.go new file mode 100644 index 0000000..a67ef7c --- /dev/null +++ b/internal/db/postgres/notification_preferences_test.go @@ -0,0 +1,193 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "sort" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func preferenceState(t *testing.T, db *sql.DB, did string) (sql.NullTime, []string) { + t.Helper() + var seenAt sql.NullTime + var disabled []string + require.NoError(t, db.QueryRow(`SELECT seen_at, disabled_reasons FROM notification_state WHERE did = $1`, did). + Scan(&seenAt, pq.Array(&disabled))) + sort.Strings(disabled) + return seenAt, disabled +} + +func TestNotificationPreferences_GetDefaultsAndDisabledReasons(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + disabled []string + hasState bool + want notifications.Preferences + }{ + {"no state defaults to enabled", nil, false, notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: true}}, + {"disabled post reply only", []string{"postReply"}, true, notifications.Preferences{PostReply: false, CommentReply: true, Mention: true, Upvote: true}}, + {"disabled comment reply only", []string{"commentReply"}, true, notifications.Preferences{PostReply: true, CommentReply: false, Mention: true, Upvote: true}}, + {"disabled mention only", []string{"mention"}, true, notifications.Preferences{PostReply: true, CommentReply: true, Mention: false, Upvote: true}}, + {"disabled upvote only", []string{"upvote"}, true, notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: false}}, + {"disabled mention and upvote", []string{"mention", "upvote"}, true, notifications.Preferences{PostReply: true, CommentReply: true, Mention: false, Upvote: false}}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + did := retentionUser(t, db) + if tc.hasState { + _, err := db.Exec(`INSERT INTO notification_state (did, disabled_reasons) VALUES ($1, $2)`, did, pq.Array(tc.disabled)) + require.NoError(t, err) + } + repo := NewNotificationRepository(db).(notifications.PreferencesRepository) + got, err := repo.GetPreferences(context.Background(), did) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestNotificationPreferences_PutChangesOnlyNamedReasons(t *testing.T) { + t.Parallel() + falseValue, trueValue := false, true + seenAt := time.Date(2026, time.September, 30, 12, 0, 0, 123456000, time.UTC).Truncate(time.Microsecond) + type step struct { + update notifications.PreferencesUpdate + want notifications.Preferences + disabled []string + } + for _, tc := range []struct { + name string + hasState bool + initialSeenAt any + initialDisabled []string + steps []step + }{ + { + name: "no row creates state without advancing seen at", + steps: []step{{notifications.PreferencesUpdate{Mention: &falseValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: false, Upvote: true}, []string{"mention"}}}, + }, + { + name: "existing seen at survives mention disable", + hasState: true, initialSeenAt: seenAt, initialDisabled: []string{}, + steps: []step{{notifications.PreferencesUpdate{Mention: &falseValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: false, Upvote: true}, []string{"mention"}}}, + }, + { + name: "mention reenabled and upvote disabled", + hasState: true, initialSeenAt: seenAt, initialDisabled: []string{"mention"}, + steps: []step{{notifications.PreferencesUpdate{Mention: &trueValue, Upvote: &falseValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: false}, []string{"upvote"}}}, + }, + { + name: "repeated put does not duplicate upvote", + hasState: true, initialSeenAt: seenAt, initialDisabled: []string{"upvote"}, + steps: []step{ + {notifications.PreferencesUpdate{Mention: &trueValue, Upvote: &falseValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: false}, []string{"upvote"}}, + {notifications.PreferencesUpdate{Mention: &trueValue, Upvote: &falseValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: false}, []string{"upvote"}}, + }, + }, + { + name: "omitted upvote remains disabled", + hasState: true, initialSeenAt: seenAt, initialDisabled: []string{"mention", "upvote"}, + steps: []step{{notifications.PreferencesUpdate{Mention: &trueValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: false}, []string{"upvote"}}}, + }, + { + name: "empty update preserves disabled comment reply", + hasState: true, initialSeenAt: seenAt, initialDisabled: []string{"commentReply"}, + steps: []step{{notifications.PreferencesUpdate{}, + notifications.Preferences{PostReply: true, CommentReply: false, Mention: true, Upvote: true}, []string{"commentReply"}}}, + }, + { + name: "post reply disabled then reenabled", + steps: []step{ + {notifications.PreferencesUpdate{PostReply: &falseValue}, + notifications.Preferences{PostReply: false, CommentReply: true, Mention: true, Upvote: true}, []string{"postReply"}}, + {notifications.PreferencesUpdate{PostReply: &trueValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: true}, []string{}}, + }, + }, + { + name: "comment reply disabled then reenabled", + steps: []step{ + {notifications.PreferencesUpdate{CommentReply: &falseValue}, + notifications.Preferences{PostReply: true, CommentReply: false, Mention: true, Upvote: true}, []string{"commentReply"}}, + {notifications.PreferencesUpdate{CommentReply: &trueValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: true}, []string{}}, + }, + }, + { + name: "upvote disabled then reenabled", + steps: []step{ + {notifications.PreferencesUpdate{Upvote: &falseValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: false}, []string{"upvote"}}, + {notifications.PreferencesUpdate{Upvote: &trueValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: true}, []string{}}, + }, + }, + { + name: "mention disabled then reenabled", + steps: []step{ + {notifications.PreferencesUpdate{Mention: &falseValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: false, Upvote: true}, []string{"mention"}}, + {notifications.PreferencesUpdate{Mention: &trueValue}, + notifications.Preferences{PostReply: true, CommentReply: true, Mention: true, Upvote: true}, []string{}}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + did := retentionUser(t, db) + if tc.hasState { + _, err := db.Exec(`INSERT INTO notification_state (did, seen_at, disabled_reasons) VALUES ($1, $2, $3)`, + did, tc.initialSeenAt, pq.Array(tc.initialDisabled)) + require.NoError(t, err) + } + repo := NewNotificationRepository(db).(notifications.PreferencesRepository) + for index, change := range tc.steps { + got, err := repo.PutPreferences(context.Background(), did, change.update) + require.NoError(t, err, "step %d", index+1) + require.Equal(t, change.want, got, "step %d return", index+1) + storedSeenAt, storedDisabled := preferenceState(t, db, did) + if tc.initialSeenAt == nil { + require.False(t, storedSeenAt.Valid, "step %d must not advance seen_at", index+1) + } else { + require.Equal(t, sql.NullTime{Time: seenAt, Valid: true}, storedSeenAt, "step %d seen_at", index+1) + } + require.Equal(t, change.disabled, storedDisabled, "step %d disabled_reasons", index+1) + } + }) + } +} + +func TestNotificationPreferences_PutForUnindexedAccountReturnsAccountNotIndexed(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + did := "did:plc:prefunindexed" + testkit.UniqueID(t) + falseValue := false + repo := NewNotificationRepository(db).(notifications.PreferencesRepository) + for _, update := range []notifications.PreferencesUpdate{{}, {Mention: &falseValue}} { + _, err := repo.PutPreferences(context.Background(), did, update) + require.True(t, errors.Is(err, notifications.ErrAccountNotIndexed), "want ErrAccountNotIndexed, got %v", err) + } + var stateRows int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM notification_state WHERE did = $1`, did).Scan(&stateRows)) + require.Zero(t, stateRows) + var userRows int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM users WHERE did = $1`, did).Scan(&userRows)) + require.Zero(t, userRows, "putPreferences must never create a users row") +} diff --git a/internal/db/postgres/notification_qualifying_upvote_guard_test.go b/internal/db/postgres/notification_qualifying_upvote_guard_test.go new file mode 100644 index 0000000..309f01f --- /dev/null +++ b/internal/db/postgres/notification_qualifying_upvote_guard_test.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +// qualifyingUpvoteSQLPanic returns what qualifyingUpvoteSQL panicked with, or +// nil when it returned normally. +func qualifyingUpvoteSQLPanic(voteAlias, subjectExpr, recipientExpr string) (recovered any) { + defer func() { recovered = recover() }() + qualifyingUpvoteSQL(voteAlias, subjectExpr, recipientExpr) + return nil +} + +// An unqualified name inside the fragment binds to the innermost scope first, +// so each of these compiles to SQL that silently asks the wrong question. +func TestQualifyingUpvoteSQL_PanicsOnArgumentsThatBindInsideTheFragment(t *testing.T) { + for _, test := range []struct { + name string + voteAlias string + subjectExpr string + recipientExpr string + badArgument string + }{ + // Compiles to v.subject_uri = v.subject_uri: every subject qualifies. + {"unqualified_subject_column", "v", "subject_uri", "$1", "subject_uri"}, + // Resolves against user_blocks and votes before any outer row. + {"unqualified_recipient_column", "v", "$1", "recipient_did", "recipient_did"}, + // The chunk-12 sweep shape: both columns left unqualified. + {"unqualified_sweep_columns", "v", "subject_uri", "recipient_did", "subject_uri"}, + {"subject_qualified_with_vote_alias", "v", "v.subject_uri", "$1", "v.subject_uri"}, + {"recipient_qualified_with_vote_alias", "v", "$1", "v.voter_did", "v.voter_did"}, + {"subject_qualified_with_reserved_erasure_alias", "v", "qualifying_upvote_erasure.did", "$1", "qualifying_upvote_erasure.did"}, + {"recipient_qualified_with_reserved_aggregator_alias", "v", "$1", "qualifying_upvote_aggregator.did", "qualifying_upvote_aggregator.did"}, + {"recipient_qualified_with_reserved_block_alias", "v", "$1", "qualifying_upvote_block.blocker_did", "qualifying_upvote_block.blocker_did"}, + {"reserved_vote_alias", "qualifying_upvote_x", "$1", "$2", "qualifying_upvote_x"}, + {"reserved_block_vote_alias", "qualifying_upvote_block", "$1", "$2", "qualifying_upvote_block"}, + {"empty_vote_alias", "", "$1", "$2", ""}, + {"vote_alias_with_space", "v w", "$1", "$2", "v w"}, + {"vote_alias_with_punctuation", "v;", "$1", "$2", "v;"}, + {"uppercase_vote_alias", "V", "$1", "$2", "V"}, + {"string_literal_subject", "v", "'at://did:plc:subject/post/1'", "$2", "'at://did:plc:subject/post/1'"}, + {"zero_placeholder", "v", "$0", "$2", "$0"}, + {"empty_recipient", "v", "$1", "", ""}, + {"expression_subject", "v", "n.subject_uri OR TRUE", "$2", "n.subject_uri OR TRUE"}, + {"quoted_alias_subject", "v", `"n".subject_uri`, "$2", `"n".subject_uri`}, + {"schema_qualified_subject", "v", "public.notifications.subject_uri", "$2", "public.notifications.subject_uri"}, + {"uppercase_outer_alias", "v", "$1", "N.recipient_did", "N.recipient_did"}, + } { + t.Run(test.name, func(t *testing.T) { + recovered := qualifyingUpvoteSQLPanic(test.voteAlias, test.subjectExpr, test.recipientExpr) + require.NotNil(t, recovered, "qualifyingUpvoteSQL(%q, %q, %q) must panic", + test.voteAlias, test.subjectExpr, test.recipientExpr) + message, ok := recovered.(string) + require.True(t, ok, "panic value must be a string message, got %T", recovered) + require.Contains(t, message, fmt.Sprintf("%q", test.badArgument), + "the panic message must name the bad argument") + }) + } +} + +func TestQualifyingUpvoteSQL_AcceptsPlaceholdersAndOuterQualifiedColumns(t *testing.T) { + for _, test := range []struct { + name string + voteAlias string + subjectExpr string + recipientExpr string + }{ + {"repository_delete_if_empty", "qualifying_vote", "$2", "$1"}, + {"placeholders", "v", "$1", "$2"}, + {"multi_digit_placeholder", "v", "$12", "$3"}, + {"outer_qualified_columns", "qv", "g.subject_uri", "g.recipient_did"}, + {"sweep_qualified_columns", "v", "n.subject_uri", "n.recipient_did"}, + {"mixed_forms", "vote_1", "$1", "notification_row.recipient_did"}, + } { + t.Run(test.name, func(t *testing.T) { + require.Nil(t, qualifyingUpvoteSQLPanic(test.voteAlias, test.subjectExpr, test.recipientExpr)) + fragment := qualifyingUpvoteSQL(test.voteAlias, test.subjectExpr, test.recipientExpr) + require.Contains(t, fragment, test.voteAlias+".subject_uri = "+test.subjectExpr) + require.Contains(t, fragment, test.voteAlias+".voter_did <> "+test.recipientExpr) + }) + } +} diff --git a/internal/db/postgres/notification_qualifying_upvote_test.go b/internal/db/postgres/notification_qualifying_upvote_test.go new file mode 100644 index 0000000..3d415d9 --- /dev/null +++ b/internal/db/postgres/notification_qualifying_upvote_test.go @@ -0,0 +1,315 @@ +//go:build integration + +package postgres + +import ( + "database/sql" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +type qualifyingUpvoteFixture struct { + db *sql.DB + uniqueID string + recipient string + voter string + control string + thirdUser string + subject string + otherPost string +} + +func newQualifyingUpvoteFixture(t *testing.T) qualifyingUpvoteFixture { + t.Helper() + db := testkit.DB(t) + id := testkit.UniqueID(t) + fixture := qualifyingUpvoteFixture{ + db: db, uniqueID: id, + recipient: "did:plc:" + id + "recipient", + voter: "did:plc:" + id + "voter", + control: "did:plc:" + id + "control", + thirdUser: "did:plc:" + id + "third", + } + fixture.subject = "at://" + fixture.recipient + "/social.coves.community.postv2/subject" + fixture.otherPost = "at://" + fixture.recipient + "/social.coves.community.postv2/control" + createTestUser(t, db, id+"recipient.test", fixture.recipient) + return fixture +} + +func (fixture qualifyingUpvoteFixture) insertVote(t *testing.T, voter, subject, direction string, createdAt time.Time, deleted bool) { + t.Helper() + key := testkit.TID() + var deletedAt *time.Time + if deleted { + deletedAt = &createdAt + } + _, err := fixture.db.Exec(`INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, deleted_at) + VALUES ($1, 'bafyqualifyingvote', $2, $3, $4, 'bafyqualifyingsubject', $5, $6, $7)`, + "at://"+voter+"/social.coves.feed.vote/"+key, key, voter, subject, direction, createdAt, deletedAt) + require.NoError(t, err) +} + +func (fixture qualifyingUpvoteFixture) insertBlock(t *testing.T, blocker, blocked string) { + t.Helper() + _, err := fixture.db.Exec(`INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) + VALUES ($1, $2, $3, 'bafyqualifyingblock')`, blocker, blocked, + "at://"+blocker+"/social.coves.actor.block/"+testkit.TID()) + require.NoError(t, err) +} + +func (fixture qualifyingUpvoteFixture) qualifies(t *testing.T, subject, recipient string) bool { + t.Helper() + var qualified bool + err := fixture.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM votes v WHERE `+ + qualifyingUpvoteSQL("v", "$1", "$2")+`)`, subject, recipient).Scan(&qualified) + require.NoError(t, err) + return qualified +} + +func TestQualifyingUpvoteSQL_LiveUpvotesIgnoreNonqualificationGates(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + setup func(*testing.T, qualifyingUpvoteFixture, time.Time) + }{ + {"indexed_voter", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + createTestUser(t, fixture.db, fixture.uniqueID+"voter.test", fixture.voter) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"voter_without_users_row", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + var users int + require.NoError(t, fixture.db.QueryRow(`SELECT count(*) FROM users WHERE did = $1`, fixture.voter).Scan(&users)) + require.Zero(t, users) + }}, + {"before_notification_activation", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + _, err := fixture.db.Exec(`UPDATE notification_activation SET activated_at = $1`, now.Add(time.Hour)) + require.NoError(t, err) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"eight_days_old", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + _, err := fixture.db.Exec(`UPDATE notification_activation SET activated_at = $1`, now.Add(-30*24*time.Hour)) + require.NoError(t, err) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now.Add(-8*24*time.Hour), false) + }}, + {"bridge_pds_voter", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + _, err := fixture.db.Exec(`INSERT INTO users (did, handle, pds_url) VALUES ($1, $2, $3)`, fixture.voter, + fixture.uniqueID+"bridge.test", "https://"+fixture.uniqueID+".bridge.test") + require.NoError(t, err) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"blocks_third_user", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertBlock(t, fixture.voter, fixture.thirdUser) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"blocked_by_third_user", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertBlock(t, fixture.thirdUser, fixture.voter) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"recipient_blocks_third_user", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertBlock(t, fixture.recipient, fixture.thirdUser) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"third_user_blocks_recipient", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertBlock(t, fixture.thirdUser, fixture.recipient) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"unrelated_erased_account", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + _, err := fixture.db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, fixture.thirdUser) + require.NoError(t, err) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"unrelated_aggregator", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + _, err := fixture.db.Exec(`INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, 'Unrelated aggregator', $2, 'bafyqualifyingaggregator')`, fixture.thirdUser, + "at://"+fixture.thirdUser+"/social.coves.aggregator.service/self") + require.NoError(t, err) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newQualifyingUpvoteFixture(t) + test.setup(t, fixture, time.Now().UTC()) + require.True(t, fixture.qualifies(t, fixture.subject, fixture.recipient), + "this live upvote must qualify regardless of users row, activation, age, PDS, unrelated blocks, erasures or aggregators") + }) + } +} + +func TestQualifyingUpvoteSQL_OnlyDisqualifiedVoteDoesNotKeepGroup(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + setup func(*testing.T, qualifyingUpvoteFixture, time.Time) + }{ + {"recipient_self_upvote", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertVote(t, fixture.recipient, fixture.subject, "up", now, false) + }}, + {"erased_voter", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + _, err := fixture.db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, fixture.voter) + require.NoError(t, err) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"aggregator_voter", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + _, err := fixture.db.Exec(`INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, 'Aggregator voter', $2, 'bafyqualifyingaggregator')`, fixture.voter, + "at://"+fixture.voter+"/social.coves.aggregator.service/self") + require.NoError(t, err) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"recipient_blocks_voter", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertBlock(t, fixture.recipient, fixture.voter) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"voter_blocks_recipient", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertBlock(t, fixture.voter, fixture.recipient) + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + }}, + {"downvote", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertVote(t, fixture.voter, fixture.subject, "down", now, false) + }}, + {"soft_deleted_upvote", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, true) + }}, + {"qualifying_upvote_only_on_other_subject", func(t *testing.T, fixture qualifyingUpvoteFixture, now time.Time) { + // The control vote below is the sole vote: the target subject has none. + }}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newQualifyingUpvoteFixture(t) + now := time.Now().UTC() + test.setup(t, fixture, now) + // A different subject under the SAME recipient is a positive control. + fixture.insertVote(t, fixture.control, fixture.otherPost, "up", now, false) + require.False(t, fixture.qualifies(t, fixture.subject, fixture.recipient), + "the target subject has no qualifying upvote") + require.True(t, fixture.qualifies(t, fixture.otherPost, fixture.recipient), + "positive control: the same query must find a qualifying vote on another subject") + }) + } +} + +func TestQualifyingUpvoteSQL_CorrelatesSubjectAndRecipientWithDifferentAlias(t *testing.T) { + t.Parallel() + fixture := newQualifyingUpvoteFixture(t) + otherRecipient := "did:plc:" + fixture.uniqueID + "otherrecipient" + createTestUser(t, fixture.db, fixture.uniqueID+"otherrecipient.test", otherRecipient) + now := time.Now().UTC() + // Each recipient's upvote on the other recipient's subject qualifies for + // that other recipient, but the voter's own query must exclude it. + fixture.insertVote(t, otherRecipient, fixture.subject, "up", now, false) + fixture.insertVote(t, fixture.recipient, fixture.otherPost, "up", now, false) + rows, err := fixture.db.Query(`WITH g(label, subject_uri, recipient_did) AS (VALUES + ('first_owner', $1::text, $2::text), + ('first_voter', $1::text, $3::text), + ('second_owner', $4::text, $3::text), + ('second_voter', $4::text, $2::text)) + SELECT g.label, EXISTS (SELECT 1 FROM votes qv WHERE `+ + qualifyingUpvoteSQL("qv", "g.subject_uri", "g.recipient_did")+`) + FROM g ORDER BY g.label`, fixture.subject, fixture.recipient, otherRecipient, fixture.otherPost) + require.NoError(t, err) + defer rows.Close() + got := make(map[string]bool) + for rows.Next() { + var label string + var qualifies bool + require.NoError(t, rows.Scan(&label, &qualifies)) + got[label] = qualifies + } + require.NoError(t, rows.Err()) + require.Equal(t, map[string]bool{ + "first_owner": true, "first_voter": false, + "second_owner": true, "second_voter": false, + }, got, "the alias and both outer columns must correlate separately for each group") +} + +// The retention sweep's shape: a DELETE over an aliased notifications row that +// embeds the fragment under NOT EXISTS. The same statement embeds it again, as +// EXISTS, over the pre-delete snapshot of those rows. Each empty group below is +// kept by exactly one wrong correlation. +func TestQualifyingUpvoteSQL_EmbedsTwiceAgainstAliasedNotificationRows(t *testing.T) { + t.Parallel() + fixture := newQualifyingUpvoteFixture(t) + otherRecipient := "did:plc:" + fixture.uniqueID + "otherrecipient" + createTestUser(t, fixture.db, fixture.uniqueID+"otherrecipient.test", otherRecipient) + selfUpvotedSubject := "at://" + otherRecipient + "/social.coves.community.postv2/selfupvoted" + blockedUpvoteSubject := "at://" + otherRecipient + "/social.coves.community.postv2/blockedupvote" + now := time.Now().UTC() + // The only qualifying upvote. It is on fixture.subject, so a lost subject + // correlation lets it keep every other group. + fixture.insertVote(t, fixture.voter, fixture.subject, "up", now, false) + // The other recipient's own upvote is the only vote on its subject, so a + // lost voter-recipient comparison keeps that group. + fixture.insertVote(t, otherRecipient, selfUpvotedSubject, "up", now, false) + // The other recipient blocks the only voter on this subject, so a + // recipient bound to anything else inside user_blocks keeps that group. + fixture.insertBlock(t, otherRecipient, fixture.thirdUser) + fixture.insertVote(t, fixture.thirdUser, blockedUpvoteSubject, "up", now, false) + + labels := map[string]string{ + fixture.recipient: "recipient", otherRecipient: "other_recipient", + fixture.subject: "subject", fixture.otherPost: "other_post", + selfUpvotedSubject: "self_upvoted", blockedUpvoteSubject: "blocked_upvote", + } + for _, group := range [][2]string{ + {fixture.recipient, fixture.subject}, + {fixture.recipient, fixture.otherPost}, + {otherRecipient, selfUpvotedSubject}, + {otherRecipient, blockedUpvoteSubject}, + } { + _, err := fixture.db.Exec(`INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri) + VALUES ($1, 'upvote', $2, $2)`, group[0], group[1]) + require.NoError(t, err) + } + + rows, err := fixture.db.Query(`WITH swept AS ( + DELETE FROM notifications n + WHERE n.reason = 'upvote' AND n.recipient_did IN ($1, $2) + AND NOT EXISTS (SELECT 1 FROM votes swept_vote WHERE `+ + qualifyingUpvoteSQL("swept_vote", "n.subject_uri", "n.recipient_did")+`) + RETURNING n.id) + SELECT kept.recipient_did, kept.subject_uri, + EXISTS (SELECT 1 FROM votes kept_vote WHERE `+ + qualifyingUpvoteSQL("kept_vote", "kept.subject_uri", "kept.recipient_did")+`), + kept.id IN (SELECT id FROM swept) + FROM notifications kept + WHERE kept.reason = 'upvote' AND kept.recipient_did IN ($1, $2)`, + fixture.recipient, otherRecipient) + require.NoError(t, err) + defer rows.Close() + type groupOutcome struct{ Qualifies, Deleted bool } + got := make(map[string]groupOutcome) + for rows.Next() { + var recipient, subject string + var outcome groupOutcome + require.NoError(t, rows.Scan(&recipient, &subject, &outcome.Qualifies, &outcome.Deleted)) + got[labels[recipient]+"/"+labels[subject]] = outcome + } + require.NoError(t, rows.Err()) + require.Equal(t, map[string]groupOutcome{ + "recipient/subject": {Qualifies: true, Deleted: false}, + "recipient/other_post": {Qualifies: false, Deleted: true}, + "other_recipient/self_upvoted": {Qualifies: false, Deleted: true}, + "other_recipient/blocked_upvote": {Qualifies: false, Deleted: true}, + }, got, "both embeddings must correlate to their own outer row's subject and recipient") + + var remaining []string + remainingRows, err := fixture.db.Query(`SELECT subject_uri FROM notifications + WHERE reason = 'upvote' AND recipient_did IN ($1, $2)`, fixture.recipient, otherRecipient) + require.NoError(t, err) + defer remainingRows.Close() + for remainingRows.Next() { + var subject string + require.NoError(t, remainingRows.Scan(&subject)) + remaining = append(remaining, labels[subject]) + } + require.NoError(t, remainingRows.Err()) + require.Equal(t, []string{"subject"}, remaining, "the sweep must leave only the group with a qualifying upvote") +} diff --git a/internal/db/postgres/notification_recipient_facts_test.go b/internal/db/postgres/notification_recipient_facts_test.go new file mode 100644 index 0000000..df488dc --- /dev/null +++ b/internal/db/postgres/notification_recipient_facts_test.go @@ -0,0 +1,102 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationRepository_RecipientFactsReadsUncommittedRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + actorDID := "did:plc:" + uniqueID + "actor" + unrelatedDID := "did:plc:" + uniqueID + "unrelated" + missingDID := "did:plc:" + uniqueID + "missing" + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + + // Only the seven recipients have entries in the expected map. Index A and + // X too, so R7's block of X is a real unrelated block, not a missing-user case. + for _, user := range []struct{ did, handle string }{ + {actorDID, uniqueID + "actor.test"}, + {unrelatedDID, uniqueID + "unrelated.test"}, + } { + _, err = transaction.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + user.did, user.handle, "https://native.pds.test") + require.NoError(t, err, "index the actor and unrelated block target") + } + + recipients := []struct { + name string + facts notifications.RecipientFacts + }{ + {name: "control"}, + {name: "erased", facts: notifications.RecipientFacts{Erased: true}}, + {name: "aggregator", facts: notifications.RecipientFacts{Aggregator: true}}, + {name: "community", facts: notifications.RecipientFacts{Community: true}}, + {name: "blockedbyactor", facts: notifications.RecipientFacts{BlockedWithActor: true}}, + {name: "blocksactor", facts: notifications.RecipientFacts{BlockedWithActor: true}}, + {name: "blocksunrelated"}, + } + recipientDIDs := make([]string, 0, len(recipients)+1) + want := make(map[string]notifications.RecipientFacts, len(recipients)) + for _, recipient := range recipients { + did := "did:plc:" + uniqueID + recipient.name + pdsURL := "https://" + uniqueID + "." + recipient.name + ".pds.test" + _, err = transaction.ExecContext(ctx, + `INSERT INTO users (did, handle, pds_url, created_at) VALUES ($1, $2, $3, NOW())`, + did, uniqueID+recipient.name+".test", pdsURL) + require.NoError(t, err, "index recipient %s", recipient.name) + recipientDIDs = append(recipientDIDs, did) + facts := recipient.facts + facts.PDSURL = pdsURL + want[did] = facts + } + + _, err = transaction.ExecContext(ctx, `INSERT INTO deleted_accounts (did) VALUES ($1), ($2)`, + recipientDIDs[1], missingDID) + require.NoError(t, err, "seed an erased indexed recipient and a marker without a users row") + _, err = transaction.ExecContext(ctx, + `INSERT INTO aggregators (did, display_name, record_uri, record_cid) VALUES ($1, $2, $3, $4)`, + recipientDIDs[2], "Notification recipient aggregator", + "at://"+recipientDIDs[2]+"/social.coves.aggregator.service/self", "bafyreirecipientfactsaggregator") + require.NoError(t, err, "declare an indexed aggregator") + _, err = transaction.ExecContext(ctx, + `INSERT INTO communities (did, handle, name, owner_did, created_by_did, hosted_by_did, pds_url, created_at) + VALUES ($1, $2, $3, $4, $4, $4, $5, NOW())`, + recipientDIDs[3], uniqueID+"community.test", "Recipient community", actorDID, + want[recipientDIDs[3]].PDSURL) + require.NoError(t, err, "declare an indexed community with the recipient's DID") + for _, block := range []struct{ blockerDID, blockedDID string }{ + {actorDID, recipientDIDs[4]}, + {recipientDIDs[5], actorDID}, + {recipientDIDs[6], unrelatedDID}, + } { + _, err = transaction.ExecContext(ctx, + `INSERT INTO user_blocks (blocker_did, blocked_did, record_uri, record_cid) VALUES ($1, $2, $3, $4)`, + block.blockerDID, block.blockedDID, + "at://"+block.blockerDID+"/social.coves.actor.block/"+testkit.TID(), "bafyreirecipientfactsblock") + require.NoError(t, err, "index the directional block within the lookup transaction") + } + + lookups := NewNotificationRepository(db).LookupsTx(transaction) + emptyFacts, err := lookups.RecipientFacts(ctx, actorDID, []string{}) + require.NoError(t, err) + require.Empty(t, emptyFacts, "no requested DIDs must yield no recipients") + + recipientDIDs = append(recipientDIDs, missingDID) + got, err := lookups.RecipientFacts(ctx, actorDID, recipientDIDs) + require.NoError(t, err) + require.Equal(t, want, got, + "the transaction must return exactly the seven indexed recipients, each with its PDS URL, erasure, aggregator, community, and directional block facts") +} diff --git a/internal/db/postgres/notification_recipient_lookups_test.go b/internal/db/postgres/notification_recipient_lookups_test.go new file mode 100644 index 0000000..0a41c76 --- /dev/null +++ b/internal/db/postgres/notification_recipient_lookups_test.go @@ -0,0 +1,36 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationRepository_IsAggregatorReadsUncommittedDeclaration(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + aggregatorDID := "did:plc:notificationaggregator" + uniqueID + otherDID := "did:plc:notificationother" + uniqueID + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + _, err = transaction.ExecContext(ctx, + `INSERT INTO aggregators (did, display_name, record_uri, record_cid) VALUES ($1, $2, $3, $4)`, + aggregatorDID, "Notification test aggregator", "at://"+aggregatorDID+"/social.coves.aggregator.service/self", "bafyreirecipientgateaggregator") + require.NoError(t, err) + + lookups := NewNotificationRepository(db).LookupsTx(transaction) + aggregator, err := lookups.IsAggregator(ctx, aggregatorDID) + require.NoError(t, err) + require.True(t, aggregator, "an uncommitted aggregator declaration must gate its recipient") + aggregator, err = lookups.IsAggregator(ctx, otherDID) + require.NoError(t, err) + require.False(t, aggregator, "an unrelated DID must not appear to be an aggregator") +} diff --git a/internal/db/postgres/notification_reference_states_test.go b/internal/db/postgres/notification_reference_states_test.go new file mode 100644 index 0000000..880f31c --- /dev/null +++ b/internal/db/postgres/notification_reference_states_test.go @@ -0,0 +1,184 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "strings" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedModerationDecision records one admin moderation decision on subjectURI in +// main's moderation tables: an action row, then the decision it made active. An +// empty scopeCommunityDID is an instance-scope decision; kind is 'removal' or +// 'label' (labels carry the value nsfw). +func seedModerationDecision(t *testing.T, db *sql.DB, subjectURI, kind, scopeCommunityDID string, active bool) { + t.Helper() + scopeKind, authority := "instance", "did:plc:notificationinstance" + var scopeCommunity any + if scopeCommunityDID != "" { + scopeKind, authority, scopeCommunity = "community", scopeCommunityDID, scopeCommunityDID + } + action, value := "remove", any(nil) + if kind == "label" { + action, value = "label", "nsfw" + } + actionID := "notification-moderation-" + testkit.TID() + _, err := db.ExecContext(context.Background(), `INSERT INTO moderation_actions + (id, actor_did, authority_did, scope_kind, scope_community_did, subject_uri, subject_collection, + action, label_value, origin, created_at) + VALUES ($1, 'did:plc:notificationmoderator', $2, $3, $4, $5, $6, $7, $8, 'local', NOW())`, + actionID, authority, scopeKind, scopeCommunity, subjectURI, strings.Split(subjectURI, "/")[3], action, value) + require.NoError(t, err) + _, err = db.ExecContext(context.Background(), `INSERT INTO moderation_decisions + (authority_did, scope_kind, scope_community_did, subject_uri, kind, value, active_action_id, active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + authority, scopeKind, scopeCommunity, subjectURI, kind, value, actionID, active) + require.NoError(t, err) +} + +func TestNotificationLookups_ReferenceStates(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + label := testkit.UniqueID(t) + community := visibilityCommunity(t, db, label+"a") + otherCommunity := visibilityCommunity(t, db, label+"b") + author := "did:plc:referenceauthor" + label + createdAt := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond) + + rows := []struct { + name string + status posts.AdmissionStatus + deleted bool + otherCommunityRemoved bool + mismatchedCID bool + removal string // "instance", "community", "both", "inactive", "label" + want notifications.ReferenceState + }{ + {name: "deleted_post", deleted: true, want: notifications.ReferenceDeleted}, + {name: "removed_post", status: posts.AdmissionStatusRemoved, want: notifications.ReferenceRemovedByModerator}, + {name: "removed_and_deleted_post", status: posts.AdmissionStatusRemoved, deleted: true, want: notifications.ReferenceRemovedByModerator}, + {name: "other_community_removed", status: posts.AdmissionStatusAccepted, otherCommunityRemoved: true, want: notifications.ReferenceLive}, + {name: "pending", status: posts.AdmissionStatusPending, want: notifications.ReferenceLive}, + {name: "rejected", status: posts.AdmissionStatusRejected, want: notifications.ReferenceLive}, + {name: "pending_reacceptance", status: posts.AdmissionStatusPendingReacceptance, want: notifications.ReferenceLive}, + {name: "accepted", status: posts.AdmissionStatusAccepted, want: notifications.ReferenceLive}, + {name: "accepted_cid_mismatch", status: posts.AdmissionStatusAccepted, mismatchedCID: true, want: notifications.ReferenceLive}, + {name: "no_admission", want: notifications.ReferenceLive}, + {name: "instance_removed_post", status: posts.AdmissionStatusAccepted, removal: "instance", want: notifications.ReferenceRemovedByServerAdmin}, + {name: "community_scope_removed_post", status: posts.AdmissionStatusAccepted, removal: "community", want: notifications.ReferenceRemovedByModerator}, + {name: "instance_and_community_scope_removed_post", status: posts.AdmissionStatusAccepted, removal: "both", want: notifications.ReferenceRemovedByServerAdmin}, + {name: "instance_removed_and_community_removed_post", status: posts.AdmissionStatusRemoved, removal: "instance", want: notifications.ReferenceRemovedByServerAdmin}, + {name: "inactive_removal_post", status: posts.AdmissionStatusAccepted, removal: "inactive", want: notifications.ReferenceLive}, + {name: "label_only_post", status: posts.AdmissionStatusAccepted, removal: "label", want: notifications.ReferenceLive}, + } + moderate := func(uri, removal string) { + t.Helper() + switch removal { + case "instance": + seedModerationDecision(t, db, uri, "removal", "", true) + case "community": + seedModerationDecision(t, db, uri, "removal", community, true) + case "both": + seedModerationDecision(t, db, uri, "removal", "", true) + seedModerationDecision(t, db, uri, "removal", community, true) + case "inactive": + seedModerationDecision(t, db, uri, "removal", "", false) + case "label": + seedModerationDecision(t, db, uri, "label", "", true) + } + } + type expectation struct { + name, uri string + want notifications.ReferenceState + } + var expected []expectation + var uris []string + for _, row := range rows { + uri := seedVisibilityPost(t, db, community, author, row.name, row.name, createdAt) + if row.status != "" { + if row.mismatchedCID { + seedVisibilityAdmissionDriftedCID(t, db, community, uri) + } else { + seedVisibilityAdmission(t, db, community, uri, row.status, "", "") + } + } + if row.otherCommunityRemoved { + seedVisibilityAdmission(t, db, otherCommunity, uri, posts.AdmissionStatusRemoved, "", "") + } + if row.deleted { + _, err := db.ExecContext(ctx, `UPDATE posts SET deleted_at = NOW() WHERE uri = $1`, uri) + require.NoError(t, err) + } + moderate(uri, row.removal) + uris = append(uris, uri) + expected = append(expected, expectation{row.name, uri, row.want}) + } + commenter := "did:plc:referencecommenter" + label + for _, row := range []struct { + name string + deleted bool + removal string + want notifications.ReferenceState + }{ + {"deleted_comment", true, "", notifications.ReferenceDeleted}, + {"live_comment", false, "", notifications.ReferenceLive}, + {"instance_removed_comment", false, "instance", notifications.ReferenceRemovedByServerAdmin}, + {"community_scope_removed_comment", false, "community", notifications.ReferenceRemovedByModerator}, + {"inactive_removal_comment", false, "inactive", notifications.ReferenceLive}, + {"label_only_comment", false, "label", notifications.ReferenceLive}, + } { + uri := seedActorComment(t, db, commenter, uris[0], row.name, createdAt) + if row.deleted { + _, err := db.ExecContext(ctx, `UPDATE comments SET deleted_at = NOW() WHERE uri = $1`, uri) + require.NoError(t, err) + } + moderate(uri, row.removal) + uris = append(uris, uri) + expected = append(expected, expectation{row.name, uri, row.want}) + } + missingURI := postV2URI(author, "unindexed") + uris = append(uris, missingURI) + expected = append(expected, expectation{"unindexed", missingURI, notifications.ReferenceUnindexed}) + missingCommentURI := "at://" + commenter + "/social.coves.community.comment/unindexed" + uris = append(uris, missingCommentURI) + expected = append(expected, expectation{"unindexed_comment", missingCommentURI, notifications.ReferenceUnindexed}) + // A decision can predate indexing (or outlive a delete): the decision alone + // classifies the URI, by scope. + for _, row := range []struct { + name, uri, removal string + want notifications.ReferenceState + }{ + {"decision_only_instance_post", postV2URI(author, "decisiononlyinstance"), "instance", notifications.ReferenceRemovedByServerAdmin}, + {"decision_only_community_scope_post", postV2URI(author, "decisiononlycommunity"), "community", notifications.ReferenceRemovedByModerator}, + {"decision_only_instance_comment", "at://" + commenter + "/social.coves.community.comment/decisiononlyinstance", "instance", notifications.ReferenceRemovedByServerAdmin}, + {"decision_only_community_scope_comment", "at://" + commenter + "/social.coves.community.comment/decisiononlycommunity", "community", notifications.ReferenceRemovedByModerator}, + {"decision_only_inactive_post", postV2URI(author, "decisiononlyinactive"), "inactive", notifications.ReferenceUnindexed}, + {"decision_only_label_post", postV2URI(author, "decisiononlylabel"), "label", notifications.ReferenceUnindexed}, + } { + moderate(row.uri, row.removal) + uris = append(uris, row.uri) + expected = append(expected, expectation{row.name, row.uri, row.want}) + } + + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + states, err := NewNotificationRepository(db).LookupsTx(transaction).ReferenceStates(ctx, uris) + require.NoError(t, err) + for _, row := range expected { + t.Run(row.name, func(t *testing.T) { + assert.Equal(t, row.want, states[row.uri], "reference state for %s", row.uri) + }) + } +} diff --git a/internal/db/postgres/notification_repo.go b/internal/db/postgres/notification_repo.go new file mode 100644 index 0000000..0ca4a75 --- /dev/null +++ b/internal/db/postgres/notification_repo.go @@ -0,0 +1,767 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "regexp" + "strings" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + + "github.com/lib/pq" +) + +// ErasureLockKeySQL is the advisory-lock key for one account's erasure, with the +// DID as $1. Delete takes the exclusive lock on it; every notification writer +// takes the shared lock on the actor's key before touching content rows. +const ErasureLockKeySQL = "hashtext('erasure:' || $1)" + +// ErrErasureGateRequiresReadCommitted is returned by ErasureGateTx for a +// transaction not at READ COMMITTED: a snapshot taken before the erasure lock +// was awaited cannot see a marker committed while waiting. +var ErrErasureGateRequiresReadCommitted = errors.New("notification erasure gate requires read committed isolation") + +// ErrNotificationActivationMissing is returned by ActivatedAt when the +// notification_activation singleton row is absent. The cutoff is unknown, so +// the caller must fail the write rather than treat it as the zero time. +var ErrNotificationActivationMissing = errors.New("notification activation row is missing") + +type postgresNotificationRepo struct { + db *sql.DB + bridgedUpvoteTotals bool + countUnreadSQL string + listSQL string + listUpvotesSQL string +} + +// NotificationRepositoryOption configures NewNotificationRepository. +type NotificationRepositoryOption func(*postgresNotificationRepo) + +// WithBridgedUpvoteTotals includes stored bridged totals in upvote groups. +// Turning it off lets the retention sweep permanently delete bridged-only +// groups; turning it back on does not restore deleted groups. +func WithBridgedUpvoteTotals() NotificationRepositoryOption { + return func(r *postgresNotificationRepo) { r.bridgedUpvoteTotals = true } +} + +// NewNotificationRepository returns the Postgres notifications repository. +func NewNotificationRepository(db *sql.DB, options ...NotificationRepositoryOption) notifications.Repository { + r := &postgresNotificationRepo{db: db} + for _, option := range options { + option(r) + } + r.countUnreadSQL = buildCountUnreadNotificationsSQL(r.bridgedUpvoteTotals) + r.listSQL = buildListNotificationsSQL(r.bridgedUpvoteTotals) + r.listUpvotesSQL = buildListNotificationUpvotesSQL(r.bridgedUpvoteTotals) + return r +} + +func (r *postgresNotificationRepo) CountsBridgedUpvoteTotals() bool { + return r.bridgedUpvoteTotals +} + +// upvoteGroupAliveSQL is the shared native-or-bridged liveness rule for a group. +func upvoteGroupAliveSQL(subjectExpr, recipientExpr string, bridgedTotals bool) string { + requireUpvoteGroupOuterExpression("subject", subjectExpr) + requireUpvoteGroupOuterExpression("recipient", recipientExpr) + alive := "EXISTS (SELECT 1 FROM votes v WHERE " + qualifyingUpvoteSQL("v", subjectExpr, recipientExpr) + ")" + if bridgedTotals { + // OFFSET 0 keeps each correlated URI probe from becoming a hashed + // full-table scan when this fragment is used in a larger visibility query. + alive += " OR EXISTS (SELECT 1 FROM posts bridged_post WHERE bridged_post.uri = " + subjectExpr + " AND bridged_post.bridged_upvote_count > 0 OFFSET 0)" + + " OR EXISTS (SELECT 1 FROM comments bridged_comment WHERE bridged_comment.uri = " + subjectExpr + " AND bridged_comment.bridged_upvote_count > 0 OFFSET 0)" + } + return "(" + alive + ")" +} + +func bridgedUpvoteTotalSQL(subjectExpr string, bridgedTotals bool) string { + requireUpvoteGroupOuterExpression("subject", subjectExpr) + if !bridgedTotals { + return "0" + } + return "COALESCE((SELECT bridged_post.bridged_upvote_count FROM posts bridged_post WHERE bridged_post.uri = " + subjectExpr + "), " + + "(SELECT bridged_comment.bridged_upvote_count FROM comments bridged_comment WHERE bridged_comment.uri = " + subjectExpr + "), 0)" +} + +func requireUpvoteGroupOuterExpression(role, expression string) { + requireQualifyingUpvoteOuterExpression(role, expression, "v") + if match := sqlQualifiedColumnPattern.FindStringSubmatch(expression); match != nil && + (match[1] == "bridged_post" || match[1] == "bridged_comment") { + panic(fmt.Sprintf("upvote group: %s expression %q uses a fragment-owned alias", role, expression)) + } +} + +// ErasureGateTx checks the marker after acquiring the lock in its own statement: +// at READ COMMITTED, this sees an erasure committed while the lock was awaited. +// The isolation level is checked before the lock, so a wrong-isolation +// transaction fails without waiting on an erasure in progress. +func (r *postgresNotificationRepo) ErasureGateTx(ctx context.Context, tx *sql.Tx, actorDID string) (bool, error) { + var isolation string + if err := tx.QueryRowContext(ctx, `SELECT current_setting('transaction_isolation')`).Scan(&isolation); err != nil { + return false, fmt.Errorf("check notification erasure gate isolation: %w", err) + } + if isolation != "read committed" { + return false, fmt.Errorf("%w, got %s", ErrErasureGateRequiresReadCommitted, isolation) + } + if _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock_shared("+ErasureLockKeySQL+")", actorDID); err != nil { + return false, fmt.Errorf("lock notification actor against erasure: %w", err) + } + var erased bool + if err := tx.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM deleted_accounts WHERE did = $1)`, actorDID).Scan(&erased); err != nil { + return false, fmt.Errorf("check notification actor erasure marker: %w", err) + } + return erased, nil +} + +func (r *postgresNotificationRepo) LookupsTx(tx *sql.Tx) notifications.Lookups { + return notificationLookups{tx: tx, indexTime: new(time.Time)} +} + +// ReplaceUpvoteGroupRootTx repoints recipientDID's upvote group on subjectURI +// when a comment is resurrected under a different root; ApplyUpvoteGroupTx +// writes the root only on insert. The recipient makes it a unique-index lookup. +// A comment's root is checked only for AT-URI shape, so rootPostURI may name a +// non-post record. root_post_uri must name a post, so the group then keeps its +// old root. +func (r *postgresNotificationRepo) ReplaceUpvoteGroupRootTx(ctx context.Context, tx *sql.Tx, recipientDID, subjectURI, rootPostURI string) error { + if !posts.IsPostCollection(posts.CollectionOfPostURI(rootPostURI)) { + return nil + } + if _, err := tx.ExecContext(ctx, `UPDATE notifications SET root_post_uri = $3 + WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, recipientDID, subjectURI, rootPostURI); err != nil { + return fmt.Errorf("replace upvote group root: %w", err) + } + return nil +} + +// RecordPostAuthorDeleteWithdrawalTx records that a publicly admitted postv2 +// was deleted by its author, before its acceptance is withdrawn. An active admin +// removal does not prevent the marker: the post was still public before the +// removal. The admission predicate deliberately does not inspect deleted_at, so +// this follows the soft delete. +func (r *postgresNotificationRepo) RecordPostAuthorDeleteWithdrawalTx(ctx context.Context, tx *sql.Tx, postURI string) error { + joinSQL, whereSQL := admittedPostsPredicate(anonymousViewerSQL) + _, err := tx.ExecContext(ctx, `INSERT INTO notification_public_post_withdrawals (post_uri, kind) + SELECT p.uri, 'authorDelete' FROM posts p`+joinSQL+` + WHERE p.uri = $1 AND split_part(p.uri, '/', 4) = '`+posts.PostV2Collection+`' + AND `+whereSQL+` + ON CONFLICT DO NOTHING`, postURI) + if err != nil { + return fmt.Errorf("record post author-delete withdrawal: %w", err) + } + return nil +} + +// RepairResurrectedCommentNotificationsTx reconciles kept rows with new +// threading before a different-parent resurrection's create fan-out. An empty +// replySubjectURI means no reply resolves, so every reply row is deleted. +func (r *postgresNotificationRepo) RepairResurrectedCommentNotificationsTx(ctx context.Context, tx *sql.Tx, recordURI, replySubjectURI, rootPostURI string) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM notifications WHERE record_uri = $1 + AND reason IN ('postReply', 'commentReply') AND ($2 = '' OR subject_uri <> $2)`, recordURI, replySubjectURI); err != nil { + return fmt.Errorf("repair resurrected comment notifications: delete old replies: %w", err) + } + // The record URI is checked only for AT-URI shape, so the new root may name a + // non-post record. root_post_uri must name a post, so the kept rows then keep + // their old root. + if !posts.IsPostCollection(posts.CollectionOfPostURI(rootPostURI)) { + return nil + } + if _, err := tx.ExecContext(ctx, `UPDATE notifications SET root_post_uri = $2 + WHERE record_uri = $1 AND root_post_uri <> $2`, recordURI, rootPostURI); err != nil { + return fmt.Errorf("repair resurrected comment notifications: update root: %w", err) + } + return nil +} + +// DeleteReplyRecipientMentionsTx applies reply precedence after a resurrection's +// fan-out: a recipient holding a reply row for the record loses its mention row. +func (r *postgresNotificationRepo) DeleteReplyRecipientMentionsTx(ctx context.Context, tx *sql.Tx, recordURI string) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM notifications WHERE reason = 'mention' AND record_uri = $1 + AND recipient_did IN (SELECT recipient_did FROM notifications + WHERE reason IN ('postReply', 'commentReply') AND record_uri = $1)`, recordURI); err != nil { + return fmt.Errorf("repair resurrected comment notifications: delete reply recipient mentions: %w", err) + } + return nil +} + +// ApplyUpvoteGroupTx inserts, bumps, or deletes an empty upvote group in the +// caller's transaction. A no-change intent executes no statement. RootPostURI +// is written only when the group is inserted; a bump raises sort_at to +// clock_timestamp() read after any wait on the group row lock, and keeps the +// stored root, so a comment re-created under a different root needs its own +// update of the group. +// +// The upsert runs in its own savepoint. A 23503 on notifications_recipient_did_fkey +// means the recipient was erased after the fan-out read its users row: the +// savepoint is rolled back, the group is skipped, and nil is returned, so the +// caller's earlier writes in tx survive. Every other error is returned and +// leaves the transaction to the caller. +// +// Delete-if-empty is one DELETE, with no savepoint, that removes the group only +// when no qualifying native upvote remains on its subject and, with bridged +// totals enabled, the bridged total is 0. The caller must hold +// the subject's posts or comments row lock, take it first, or re-check in a +// separate statement. At READ COMMITTED a DELETE that waits on a concurrent +// bump re-checks the bumped row but evaluates NOT EXISTS against its original +// snapshot, so without that lock it deletes a group whose new upvote is live. +func (r *postgresNotificationRepo) ApplyUpvoteGroupTx(ctx context.Context, tx *sql.Tx, intent notifications.UpvoteGroupIntent) error { + switch intent.Action { + case notifications.UpvoteGroupNoChange: + return nil + case notifications.UpvoteGroupBump: + if _, err := tx.ExecContext(ctx, `SAVEPOINT notification_upvote_group`); err != nil { + return fmt.Errorf("save upvote group: %w", err) + } + _, err := tx.ExecContext(ctx, `INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $3, clock_timestamp()) + ON CONFLICT (recipient_did, subject_uri) WHERE reason = 'upvote' + DO UPDATE SET sort_at = GREATEST(notifications.sort_at, clock_timestamp())`, + intent.RecipientDID, intent.SubjectURI, intent.RootPostURI) + if err != nil { + if !isRecipientForeignKeyViolation(err) { + return fmt.Errorf("upsert upvote group: %w", err) + } + if _, rollbackError := tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT notification_upvote_group`); rollbackError != nil { + return fmt.Errorf("rollback erased upvote group recipient: %w (after upsert error: %w)", rollbackError, err) + } + if _, releaseError := tx.ExecContext(ctx, `RELEASE SAVEPOINT notification_upvote_group`); releaseError != nil { + return fmt.Errorf("release erased upvote group recipient: %w (after upsert error: %w)", releaseError, err) + } + slog.DebugContext(ctx, "skipped upvote group for recipient erased during upsert", + "subject_uri", intent.SubjectURI, + "recipient_did", intent.RecipientDID, + ) + return nil + } + if _, err := tx.ExecContext(ctx, `RELEASE SAVEPOINT notification_upvote_group`); err != nil { + return fmt.Errorf("release upvote group: %w", err) + } + return nil + case notifications.UpvoteGroupDeleteIfEmpty: + if _, err := tx.ExecContext(ctx, `DELETE FROM notifications + WHERE recipient_did = $1 AND subject_uri = $2 AND reason = 'upvote' + AND NOT `+upvoteGroupAliveSQL("$2", "$1", r.bridgedUpvoteTotals), + intent.RecipientDID, intent.SubjectURI); err != nil { + return fmt.Errorf("delete upvote group: %w", err) + } + return nil + default: + return fmt.Errorf("unknown upvote group action: %d", intent.Action) + } +} + +// ApplyTx inserts intents through tx, the caller's index transaction, and +// leaves ending tx to the caller. +// +// Every savepoint that writes allocates a subtransaction XID, and Postgres +// caches only 64 of them per backend. Past that the cache overflows, and +// snapshot visibility checks must look up pg_subtrans. The common path +// therefore writes all intents with one INSERT inside one savepoint, +// notification_batch. The savepoint exists so that a failed batch can be rolled +// back without discarding the caller's earlier writes in tx. +// +// Only a 23503 on notifications_recipient_did_fkey is an expected outcome: a +// recipient was erased (erasure deletes its users row) between the fan-out's +// recipient-facts read and this insert. ApplyTx then rolls the batch back and +// inserts each intent in its own savepoint, skipping erased recipients. That +// fallback opens one savepoint per intent, up to one reply plus +// richtext.MaxFacets mentions, so in this rare race it can exceed the 64-entry +// cache. Every other error is a defect or a transient failure and is returned, +// so the caller aborts the index transaction and the event is retried or +// dead-lettered. +func (r *postgresNotificationRepo) ApplyTx(ctx context.Context, tx *sql.Tx, intents []notifications.Intent) error { + if len(intents) == 0 { + return nil + } + recipients := make([]string, 0, len(intents)) + reasons := make([]string, 0, len(intents)) + recordURIs := make([]string, 0, len(intents)) + recordCIDs := make([]string, 0, len(intents)) + actors := make([]string, 0, len(intents)) + subjectURIs := make([]string, 0, len(intents)) + rootPostURIs := make([]string, 0, len(intents)) + createdAt := make([]time.Time, 0, len(intents)) + for _, intent := range intents { + recipients = append(recipients, intent.RecipientDID) + reasons = append(reasons, string(intent.Reason)) + recordURIs = append(recordURIs, intent.RecordURI) + recordCIDs = append(recordCIDs, intent.RecordCID) + actors = append(actors, intent.ActorDID) + subjectURIs = append(subjectURIs, intent.SubjectURI) + rootPostURIs = append(rootPostURIs, intent.RootPostURI) + createdAt = append(createdAt, intent.RecordCreatedAt) + } + if _, err := tx.ExecContext(ctx, `SAVEPOINT notification_batch`); err != nil { + return fmt.Errorf("save notification batch: %w", err) + } + // sort_at is clock_timestamp(), not the column default now() (transaction + // start), so time spent waiting on locks before this write no longer makes + // the row look older than a seen_at set meanwhile. A small INSERT-to-COMMIT + // window remains: an updateSeen landing between this write and commit still + // marks the row read. + _, err := tx.ExecContext(ctx, ` + INSERT INTO notifications (recipient_did, reason, record_uri, record_cid, actor_did, + subject_uri, root_post_uri, record_created_at, sort_at) + SELECT recipient, reason, NULLIF(record_uri, ''), NULLIF(record_cid, ''), NULLIF(actor, ''), + NULLIF(subject_uri, ''), root_post_uri, created_at, clock_timestamp() + FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::text[], + $6::text[], $7::text[], $8::timestamptz[]) + AS intent(recipient, reason, record_uri, record_cid, actor, subject_uri, root_post_uri, created_at) + ON CONFLICT (recipient_did, reason, record_uri) WHERE reason <> 'upvote' DO NOTHING`, + pq.Array(recipients), pq.Array(reasons), pq.Array(recordURIs), pq.Array(recordCIDs), + pq.Array(actors), pq.Array(subjectURIs), pq.Array(rootPostURIs), pq.Array(createdAt)) + if err == nil { + if _, err := tx.ExecContext(ctx, `RELEASE SAVEPOINT notification_batch`); err != nil { + return fmt.Errorf("release notification batch: %w", err) + } + return nil + } + if !isRecipientForeignKeyViolation(err) { + return fmt.Errorf("insert notification batch: %w", err) + } + if _, rollbackError := tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT notification_batch`); rollbackError != nil { + return fmt.Errorf("rollback notification batch: %w (after insert error: %w)", rollbackError, err) + } + if _, releaseError := tx.ExecContext(ctx, `RELEASE SAVEPOINT notification_batch`); releaseError != nil { + return fmt.Errorf("release notification batch: %w (after insert error: %w)", releaseError, err) + } + slog.InfoContext(ctx, "notification batch hit an erased recipient, inserting intents one at a time", + "record_uri", intents[0].RecordURI, + "intent_count", len(intents), + ) + for _, intent := range intents { + if _, err := tx.ExecContext(ctx, `SAVEPOINT notification_intent`); err != nil { + return fmt.Errorf("save notification intent: %w", err) + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO notifications (recipient_did, reason, record_uri, record_cid, actor_did, + subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, clock_timestamp()) + ON CONFLICT (recipient_did, reason, record_uri) WHERE reason <> 'upvote' DO NOTHING`, + intent.RecipientDID, intent.Reason, nullString(intent.RecordURI), nullString(intent.RecordCID), + nullString(intent.ActorDID), nullString(intent.SubjectURI), intent.RootPostURI, intent.RecordCreatedAt) + if err != nil { + if isRecipientForeignKeyViolation(err) { + if _, rollbackError := tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT notification_intent`); rollbackError != nil { + return fmt.Errorf("rollback erased notification recipient intent: %w (after insert error: %w)", rollbackError, err) + } + if _, releaseError := tx.ExecContext(ctx, `RELEASE SAVEPOINT notification_intent`); releaseError != nil { + return fmt.Errorf("release erased notification recipient intent: %w (after insert error: %w)", releaseError, err) + } + slog.DebugContext(ctx, "skipped notification for recipient erased during insert", + "reason", intent.Reason, + "record_uri", intent.RecordURI, + "recipient_did", intent.RecipientDID, + ) + continue + } + return fmt.Errorf("insert notification intent: %w", err) + } + if _, err := tx.ExecContext(ctx, `RELEASE SAVEPOINT notification_intent`); err != nil { + return fmt.Errorf("release notification intent: %w", err) + } + } + return nil +} + +// isRecipientForeignKeyViolation reports whether err is the foreign-key +// violation raised when a notification's recipient has no users row. +func isRecipientForeignKeyViolation(err error) bool { + var databaseError *pq.Error + return errors.As(err, &databaseError) && databaseError.Code == "23503" && + databaseError.Constraint == "notifications_recipient_did_fkey" +} + +type notificationLookups struct { + tx *sql.Tx + // indexTime memoizes now() read alongside activated_at: now() is constant + // within a transaction, so IndexTime can reuse it without a second round + // trip. LookupsTx always allocates it; the zero value means neither + // ActivatedAt nor IndexTime has read it yet. + indexTime *time.Time +} + +func (l notificationLookups) LegacyPostAuthor(ctx context.Context, postURI string) (string, bool, error) { + var authorDID string + err := l.tx.QueryRowContext(ctx, `SELECT author_did FROM posts WHERE uri = $1`, postURI).Scan(&authorDID) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("look up legacy post author: %w", err) + } + return authorDID, true, nil +} + +func (l notificationLookups) ActivatedAt(ctx context.Context) (time.Time, error) { + var activatedAt, indexTime time.Time + err := l.tx.QueryRowContext(ctx, `SELECT activated_at, now() FROM notification_activation`).Scan(&activatedAt, &indexTime) + if errors.Is(err, sql.ErrNoRows) { + return time.Time{}, fmt.Errorf("select notification_activation: %w", ErrNotificationActivationMissing) + } + if err != nil { + return time.Time{}, fmt.Errorf("select notification_activation: %w", err) + } + *l.indexTime = indexTime + return activatedAt, nil +} + +func (l notificationLookups) IndexTime(ctx context.Context) (time.Time, error) { + if !l.indexTime.IsZero() { + return *l.indexTime, nil + } + var indexTime time.Time + if err := l.tx.QueryRowContext(ctx, `SELECT now()`).Scan(&indexTime); err != nil { + return time.Time{}, fmt.Errorf("select now(): %w", err) + } + *l.indexTime = indexTime + return indexTime, nil +} + +func (l notificationLookups) IsAggregator(ctx context.Context, did string) (bool, error) { + var aggregator bool + if err := l.tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM aggregators WHERE did = $1)`, did).Scan(&aggregator); err != nil { + return false, fmt.Errorf("select aggregators: %w", err) + } + return aggregator, nil +} + +// ExistingMentionRecipients reads surviving mention recipients for a record +// inside the caller's index transaction. +func (l notificationLookups) ExistingMentionRecipients(ctx context.Context, recordURI string) ([]string, error) { + rows, err := l.tx.QueryContext(ctx, + `SELECT recipient_did FROM notifications WHERE reason = 'mention' AND record_uri = $1`, recordURI) + if err != nil { + return nil, fmt.Errorf("select existing mention recipients: %w", err) + } + defer rows.Close() + var recipients []string + for rows.Next() { + var recipientDID string + if err := rows.Scan(&recipientDID); err != nil { + return nil, fmt.Errorf("select existing mention recipients: %w", err) + } + recipients = append(recipients, recipientDID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("select existing mention recipients: %w", err) + } + return recipients, nil +} + +func (l notificationLookups) EarlierUpvoteExists(ctx context.Context, voterDID, subjectURI, voteURI string) (bool, error) { + var exists bool + if err := l.tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM votes WHERE voter_did = $1 AND subject_uri = $2 AND direction = 'up' AND uri <> $3)`, + voterDID, subjectURI, voteURI).Scan(&exists); err != nil { + return false, fmt.Errorf("select earlier upvotes: %w", err) + } + return exists, nil +} + +func (l notificationLookups) RecipientFacts(ctx context.Context, actorDID string, recipientDIDs []string) (map[string]notifications.RecipientFacts, error) { + facts := make(map[string]notifications.RecipientFacts, len(recipientDIDs)) + if len(recipientDIDs) == 0 { + return facts, nil + } + rows, err := l.tx.QueryContext(ctx, ` + SELECT u.did, u.pds_url, + EXISTS(SELECT 1 FROM deleted_accounts WHERE did = u.did), + EXISTS(SELECT 1 FROM aggregators WHERE did = u.did), + EXISTS(SELECT 1 FROM communities WHERE did = u.did), + EXISTS(SELECT 1 FROM user_blocks WHERE + (blocker_did = u.did AND blocked_did = $1) OR + (blocker_did = $1 AND blocked_did = u.did)) + FROM users u WHERE u.did = ANY($2)`, actorDID, pq.Array(recipientDIDs)) + if err != nil { + return nil, fmt.Errorf("query notification recipient facts: %w", err) + } + defer rows.Close() + for rows.Next() { + var did string + var recipient notifications.RecipientFacts + if err := rows.Scan(&did, &recipient.PDSURL, &recipient.Erased, &recipient.Aggregator, + &recipient.Community, &recipient.BlockedWithActor); err != nil { + return nil, fmt.Errorf("scan notification recipient facts: %w", err) + } + facts[did] = recipient + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate notification recipient facts: %w", err) + } + return facts, nil +} + +var ( + sqlIdentifierPattern = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`) + sqlPlaceholderPattern = regexp.MustCompile(`^\$[1-9][0-9]*$`) + sqlQualifiedColumnPattern = regexp.MustCompile(`^([a-z_][a-z0-9_]*)\.[a-z_][a-z0-9_]*$`) +) + +// qualifyingUpvoteReservedAliasPrefix starts every alias qualifyingUpvoteSQL +// gives its own subqueries. +const qualifyingUpvoteReservedAliasPrefix = "qualifying_upvote_" + +// qualifyingUpvoteSQL is the condition under which the votes row aliased +// voteAlias is a live qualifying upvote on subjectExpr for recipientExpr. +// +// The arguments are spliced into SQL, so they are constants, never values. +// voteAlias is a lowercase identifier. subjectExpr and recipientExpr are each a +// bind placeholder ($2) or a column qualified with an outer alias +// (n.subject_uri), never with voteAlias. The fragment resolves an unqualified +// name against votes and user_blocks before any outer row, so a bare +// subject_uri compiles to voteAlias.subject_uri = voteAlias.subject_uri, which +// is always true. Aliases starting with qualifying_upvote_ are reserved for +// the fragment's own subqueries. Any other argument panics. +// +// A DELETE guarded by NOT EXISTS over this fragment is correct only while the +// caller holds the subject's posts or comments row lock, takes it first, or +// re-checks in a separate statement; ApplyUpvoteGroupTx says why. +func qualifyingUpvoteSQL(voteAlias, subjectExpr, recipientExpr string) string { + if !sqlIdentifierPattern.MatchString(voteAlias) || strings.HasPrefix(voteAlias, qualifyingUpvoteReservedAliasPrefix) { + panic(fmt.Sprintf("qualifyingUpvoteSQL: vote alias %q must be a lowercase identifier not starting with %q", + voteAlias, qualifyingUpvoteReservedAliasPrefix)) + } + requireQualifyingUpvoteOuterExpression("subject", subjectExpr, voteAlias) + requireQualifyingUpvoteOuterExpression("recipient", recipientExpr, voteAlias) + return fmt.Sprintf(`(%[1]s.subject_uri = %[2]s + AND %[1]s.deleted_at IS NULL + AND %[1]s.direction = 'up' + AND %[1]s.voter_did <> %[3]s + AND NOT EXISTS (SELECT 1 FROM deleted_accounts qualifying_upvote_erasure + WHERE qualifying_upvote_erasure.did = %[1]s.voter_did) + AND NOT EXISTS (SELECT 1 FROM aggregators qualifying_upvote_aggregator + WHERE qualifying_upvote_aggregator.did = %[1]s.voter_did) + AND NOT EXISTS (SELECT 1 FROM user_blocks qualifying_upvote_block + WHERE (qualifying_upvote_block.blocker_did = %[1]s.voter_did + AND qualifying_upvote_block.blocked_did = %[3]s) + OR (qualifying_upvote_block.blocker_did = %[3]s + AND qualifying_upvote_block.blocked_did = %[1]s.voter_did)))`, + voteAlias, subjectExpr, recipientExpr) +} + +// requireQualifyingUpvoteOuterExpression panics unless expression is a bind +// placeholder or a column qualified with an alias from outside the fragment. +func requireQualifyingUpvoteOuterExpression(role, expression, voteAlias string) { + if sqlPlaceholderPattern.MatchString(expression) { + return + } + match := sqlQualifiedColumnPattern.FindStringSubmatch(expression) + if match == nil { + panic(fmt.Sprintf("qualifyingUpvoteSQL: %s expression %q must be a bind placeholder ($N) or an outer-alias-qualified column (alias.column)", + role, expression)) + } + if outerAlias := match[1]; outerAlias == voteAlias || strings.HasPrefix(outerAlias, qualifyingUpvoteReservedAliasPrefix) { + panic(fmt.Sprintf("qualifyingUpvoteSQL: %s expression %q is qualified with %q, an alias the fragment binds itself; qualify it with an outer alias", + role, expression, outerAlias)) + } +} + +// SweepReadNotifications deletes rows older than each recipient's last seen +// position, falling back to that recipient's newest row when never seen. +func (r *postgresNotificationRepo) SweepReadNotifications(ctx context.Context) (int64, error) { + return r.sweepNotificationDelete(ctx, `DELETE FROM notifications WHERE id IN ( + SELECT n.id FROM notifications n + LEFT JOIN notification_state state ON state.did = n.recipient_did + WHERE n.sort_at < COALESCE(state.seen_at, + (SELECT newest.sort_at FROM notifications newest + WHERE newest.recipient_did = n.recipient_did + ORDER BY newest.sort_at DESC, newest.id DESC LIMIT 1)) + - ($1::bigint * INTERVAL '1 hour') + LIMIT $2 FOR UPDATE OF n SKIP LOCKED + )`, int64(notifications.RetentionReadWindow/time.Hour), notifications.RetentionBatchSize) +} + +// SweepUnreadOverflow removes old unread rows only for recipients whose unread +// count exceeds the cap. The count and newest timestamp reflect this statement. +func (r *postgresNotificationRepo) SweepUnreadOverflow(ctx context.Context) (int64, error) { + return r.sweepNotificationDelete(ctx, `WITH overflowing AS MATERIALIZED ( + SELECT state.did, state.seen_at, MAX(all_rows.sort_at) AS newest + FROM notification_state state + JOIN notifications all_rows ON all_rows.recipient_did = state.did + WHERE state.seen_at IS NOT NULL + GROUP BY state.did, state.seen_at + HAVING COUNT(*) FILTER (WHERE all_rows.sort_at > state.seen_at) > $1 + ) + DELETE FROM notifications WHERE id IN ( + SELECT n.id FROM notifications n + JOIN overflowing ON overflowing.did = n.recipient_did + WHERE n.sort_at > overflowing.seen_at + AND n.sort_at < overflowing.newest - ($2::bigint * INTERVAL '1 hour') + LIMIT $3 FOR UPDATE OF n SKIP LOCKED + )`, notifications.RetentionUnreadCap, int64(notifications.RetentionUnreadWindow/time.Hour), notifications.RetentionBatchSize) +} + +// sweepNotificationDelete runs one bounded DELETE in its own transaction. +func (r *postgresNotificationRepo) sweepNotificationDelete(ctx context.Context, query string, args ...any) (int64, error) { + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return 0, fmt.Errorf("begin notification retention sweep: %w", err) + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return 0, fmt.Errorf("delete notification retention batch: %w", err) + } + removed, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("count notification retention batch: %w", err) + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit notification retention sweep: %w", err) + } + return removed, nil +} + +var _ notifications.RetentionSweeper = (*postgresNotificationRepo)(nil) + +// sweepHiddenReferenceNotificationsSQL selects through the read-side reference +// states only, so a row hidden by a block, a disabled preference or the upvote +// alive rule is kept; placeholders (deleted, removed) never read hidden. +var sweepHiddenReferenceNotificationsSQL = func() string { + visibility := notificationVisibility(false) + return `DELETE FROM notifications WHERE id IN ( + SELECT n.id FROM notifications n` + visibility.joins + ` + WHERE n.sort_at < now() - ($1::bigint * INTERVAL '1 hour') + AND NOT (` + visibility.referencesVisible + `) + LIMIT $2 FOR UPDATE OF n SKIP LOCKED + )` +}() + +// SweepHiddenReferenceNotifications deletes rows whose required reference reads +// hidden and whose sort_at is older than now() - RetentionHiddenReferenceWindow: +// such a row never lists, and a reference that stayed hidden that long is not +// expected to become public. There is no lower age bound, so a row missed during +// downtime or hidden after it aged past the window is still removed. The lateral +// reference lookups run across that old tail, bounded per statement by +// RetentionBatchSize. +func (r *postgresNotificationRepo) SweepHiddenReferenceNotifications(ctx context.Context) (int64, error) { + return r.sweepNotificationDelete(ctx, sweepHiddenReferenceNotificationsSQL, + int64(notifications.RetentionHiddenReferenceWindow/time.Hour), notifications.RetentionBatchSize) +} + +// SweepEmptyUpvoteGroups locks empty candidates, then re-checks eligibility in +// a second statement with a fresh READ COMMITTED snapshot. A concurrent bump +// either precedes that snapshot or waits for these row locks to be released. +func (r *postgresNotificationRepo) SweepEmptyUpvoteGroups(ctx context.Context) (int64, error) { + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return 0, fmt.Errorf("begin empty upvote group sweep: %w", err) + } + defer tx.Rollback() + alive := upvoteGroupAliveSQL("n.subject_uri", "n.recipient_did", r.bridgedUpvoteTotals) + rows, err := tx.QueryContext(ctx, `SELECT n.id FROM notifications n + WHERE n.reason = 'upvote' AND NOT `+alive+` + LIMIT $1 FOR UPDATE OF n SKIP LOCKED`, notifications.RetentionBatchSize) + if err != nil { + return 0, fmt.Errorf("select empty upvote group candidates: %w", err) + } + var candidateIDs []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return 0, fmt.Errorf("scan empty upvote group candidates: %w", err) + } + candidateIDs = append(candidateIDs, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, fmt.Errorf("read empty upvote group candidates: %w", err) + } + if err := rows.Close(); err != nil { + return 0, fmt.Errorf("close empty upvote group candidates: %w", err) + } + if len(candidateIDs) == 0 { + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit empty upvote group sweep: %w", err) + } + return 0, nil + } + result, err := tx.ExecContext(ctx, `DELETE FROM notifications n + WHERE n.id = ANY($1::bigint[]) AND NOT `+alive, pq.Array(candidateIDs)) + if err != nil { + return 0, fmt.Errorf("delete empty upvote groups: %w", err) + } + removed, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("count empty upvote groups: %w", err) + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit empty upvote group sweep: %w", err) + } + return removed, nil +} + +// ReferenceStates classifies withdrawn references in one read through the index +// transaction. Indexed live references are absent from the result. A post or +// comment URI with no indexed row is Unindexed, unless an active admin removal +// decision names it (the decision can predate indexing or outlive a delete), in +// which case the removal classifies it. A URI in any other collection can never +// be indexed, so it is not a withdrawable reference and stays absent (a comment +// whose parent is an unsupported record still notifies its mentions). +// Precedence follows the read side: a community-removed post reads +// RemovedByServerAdmin under an active instance removal, else +// RemovedByModerator; a deleted row stays Deleted; otherwise any active +// instance removal is RemovedByServerAdmin and community-scope-only removals are +// RemovedByModerator. Label decisions and inactive removals add nothing. +func (l notificationLookups) ReferenceStates(ctx context.Context, uris []string) (map[string]notifications.ReferenceState, error) { + states := make(map[string]notifications.ReferenceState) + if len(uris) == 0 { + return states, nil + } + rows, err := l.tx.QueryContext(ctx, ` + WITH removals AS ( + SELECT d.subject_uri AS uri, bool_or(d.scope_kind = 'instance') AS by_instance + FROM moderation_decisions d + WHERE d.subject_uri = ANY($1::text[]) AND d.kind = 'removal' AND d.active + GROUP BY d.subject_uri + ), indexed AS ( + SELECT c.uri, c.deleted_at IS NOT NULL AS deleted, false AS community_removed + FROM comments c + WHERE c.uri = ANY($1::text[]) + UNION ALL + SELECT p.uri, p.deleted_at IS NOT NULL, COALESCE(a.status = 'removed', false) + FROM posts p + LEFT JOIN community_post_admissions a + ON a.community_did = p.community_did AND a.post_uri = p.uri + WHERE p.uri = ANY($1::text[]) + ) + SELECT uri, state FROM ( + SELECT requested.uri, CASE + WHEN i.community_removed THEN CASE WHEN r.by_instance THEN $4::int ELSE $3::int END + WHEN i.deleted THEN $2::int + WHEN r.by_instance THEN $4::int + WHEN r.uri IS NOT NULL THEN $3::int + WHEN i.uri IS NULL AND split_part(requested.uri, '/', 4) = ANY($6::text[]) THEN $5::int + END AS state + FROM (SELECT DISTINCT unnest($1::text[]) AS uri) requested + LEFT JOIN indexed i ON i.uri = requested.uri + LEFT JOIN removals r ON r.uri = requested.uri + ) classified + WHERE state IS NOT NULL`, + pq.Array(uris), int(notifications.ReferenceDeleted), int(notifications.ReferenceRemovedByModerator), + int(notifications.ReferenceRemovedByServerAdmin), int(notifications.ReferenceUnindexed), + pq.Array([]string{posts.PostV2Collection, posts.LegacyPostCollection, "social.coves.community.comment"})) + if err != nil { + return nil, fmt.Errorf("query notification reference states: %w", err) + } + defer rows.Close() + for rows.Next() { + var uri string + var state notifications.ReferenceState + if err := rows.Scan(&uri, &state); err != nil { + return nil, fmt.Errorf("scan notification reference states: %w", err) + } + states[uri] = state + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate notification reference states: %w", err) + } + return states, nil +} diff --git a/internal/db/postgres/notification_repo_test.go b/internal/db/postgres/notification_repo_test.go new file mode 100644 index 0000000..3fb33bc --- /dev/null +++ b/internal/db/postgres/notification_repo_test.go @@ -0,0 +1,219 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func TestNotificationRepository_LookupsTx(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + authorDID := "did:plc:notificationauthor" + uniqueID + communityDID := "did:plc:notificationcommunity" + uniqueID + createTestUser(t, db, "notificationauthor"+uniqueID+".test", authorDID) + createTestCommunity(t, db, communityDID, "c.notification"+uniqueID, authorDID) + postURI := "at://" + communityDID + "/social.coves.community.post/legacy" + _, err := db.ExecContext(ctx, ` + INSERT INTO posts (uri, cid, rkey, author_did, community_did, title, created_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW())`, + postURI, "bafyrenotificationlegacy", "legacy", authorDID, communityDID, "Legacy post") + require.NoError(t, err, "seed a legacy post whose author is not its URI authority") + + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + lookups := NewNotificationRepository(db).LookupsTx(transaction) + postAuthorDID, found, err := lookups.LegacyPostAuthor(ctx, postURI) + require.NoError(t, err) + require.True(t, found, "LegacyPostAuthor must find the indexed legacy post") + require.Equal(t, authorDID, postAuthorDID, "legacy post author is posts.author_did, not the URI's community DID") + postAuthorDID, found, err = lookups.LegacyPostAuthor(ctx, + "at://"+communityDID+"/social.coves.community.post/missing") + require.NoError(t, err) + require.False(t, found, "a missing legacy post has no author") + require.Empty(t, postAuthorDID) +} + +func TestNotificationRepository_ApplyTx_ReplyIntentMappingAndIdempotency(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + reason notifications.Reason + }{ + {"postReply", notifications.ReasonPostReply}, + {"commentReply", notifications.ReasonCommentReply}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + recipientDID := "did:plc:notificationrecipient" + uniqueID + actorDID := "did:plc:notificationactor" + uniqueID // Deliberately no users row. + createTestUser(t, db, "notificationrecipient"+uniqueID+".test", recipientDID) + createdAt := time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC) + rootPostURI := "at://" + recipientDID + "/social.coves.community.postv2/root" + subjectURI := rootPostURI + if test.reason == notifications.ReasonCommentReply { + subjectURI = "at://" + recipientDID + "/social.coves.community.comment/parent" + } + intent := notifications.Intent{ + Reason: test.reason, RecipientDID: recipientDID, ActorDID: actorDID, + RecordURI: "at://" + actorDID + "/social.coves.community.comment/reply", + RecordCID: "bafyrenotificationreply", SubjectURI: subjectURI, + RootPostURI: rootPostURI, RecordCreatedAt: createdAt, + } + repository := NewNotificationRepository(db) + + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + // sort_at is the write statement's clock_timestamp(), so it falls between + // clock reads taken around the first ApplyTx and after the transaction's + // now(), which a transaction-start sort_at would equal. + var firstTransactionTime, clockBeforeWrite, clockAfterWrite time.Time + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT now(), clock_timestamp()`). + Scan(&firstTransactionTime, &clockBeforeWrite)) + require.Truef(t, clockBeforeWrite.After(firstTransactionTime), + "fixture: clock read %s must follow transaction start %s", clockBeforeWrite, firstTransactionTime) + require.NoError(t, repository.ApplyTx(ctx, transaction, []notifications.Intent{intent})) + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT clock_timestamp()`).Scan(&clockAfterWrite)) + require.NoError(t, repository.ApplyTx(ctx, transaction, []notifications.Intent{intent}), + "reapplying a reply intent in the same transaction must be idempotent") + require.NoError(t, transaction.Commit()) + + var count int + require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM notifications`).Scan(&count)) + require.Equal(t, 1, count, "missing %s notification after committing the intent twice", test.reason) + var recipient, reason, actor, recordURI, recordCID, subject, rootPost string + var storedCreatedAt, sortAt time.Time + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT recipient_did, reason, actor_did, record_uri, record_cid, subject_uri, + root_post_uri, record_created_at, sort_at FROM notifications`, + ).Scan(&recipient, &reason, &actor, &recordURI, &recordCID, &subject, &rootPost, + &storedCreatedAt, &sortAt)) + require.Equal(t, recipientDID, recipient) + require.Equal(t, string(test.reason), reason) + require.Equal(t, actorDID, actor) + require.Equal(t, intent.RecordURI, recordURI) + require.Equal(t, intent.RecordCID, recordCID) + require.Equal(t, subjectURI, subject) + require.Equal(t, rootPostURI, rootPost) + require.Truef(t, storedCreatedAt.Equal(createdAt), + "record_created_at = %s, want record createdAt %s", storedCreatedAt, createdAt) + require.Falsef(t, sortAt.Before(clockBeforeWrite) || sortAt.After(clockAfterWrite), + "sort_at = %s, want the first write's clock_timestamp() in [%s, %s]", + sortAt, clockBeforeWrite, clockAfterWrite) + firstSortAt := sortAt + + laterTransaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer laterTransaction.Rollback() + require.NoError(t, repository.ApplyTx(ctx, laterTransaction, []notifications.Intent{intent}), + "reapplying a committed intent in a later transaction must be idempotent") + require.NoError(t, laterTransaction.Commit()) + require.NoError(t, db.QueryRowContext(ctx, `SELECT count(*) FROM notifications`).Scan(&count)) + require.Equal(t, 1, count, "reapplying the same reply must leave exactly one row") + require.NoError(t, db.QueryRowContext(ctx, + `SELECT sort_at FROM notifications WHERE recipient_did = $1 AND reason = $2 AND record_uri = $3`, + recipientDID, test.reason, intent.RecordURI).Scan(&sortAt)) + require.Truef(t, sortAt.Equal(firstSortAt), + "a duplicate reply must preserve the first write's sort_at: got %s, want %s", + sortAt, firstSortAt) + }) + } +} + +func TestNotificationRepository_ApplyTx_PropagatesConstraintViolation(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + recipientDID := "did:plc:notificationrecipient" + uniqueID + createTestUser(t, db, "notificationrecipient"+uniqueID+".test", recipientDID) + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + postURI := "at://" + recipientDID + "/social.coves.community.postv2/post" + err = NewNotificationRepository(db).ApplyTx(ctx, transaction, []notifications.Intent{{ + Reason: "bogus", RecipientDID: recipientDID, ActorDID: "did:plc:notificationactor" + uniqueID, + RecordURI: "at://did:plc:notificationactor" + uniqueID + "/social.coves.community.comment/reply", + RecordCID: "bafyrenotificationreply", SubjectURI: postURI, RootPostURI: postURI, + RecordCreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }}) + require.Error(t, err, "ApplyTx must return the notifications CHECK-constraint violation") + var postgresError *pq.Error + require.True(t, errors.As(err, &postgresError), "the returned error must wrap the Postgres error") + require.Equal(t, pq.ErrorCode("23514"), postgresError.Code) +} + +func TestNotificationRepository_ApplyTx_RejectsReplyWithoutSubject(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + recipientDID := "did:plc:notificationrecipient" + uniqueID + actorDID := "did:plc:notificationactor" + uniqueID + createTestUser(t, db, "notificationrecipient"+uniqueID+".test", recipientDID) + recordURI := "at://" + actorDID + "/social.coves.community.comment/reply" + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + applyError := NewNotificationRepository(db).ApplyTx(ctx, transaction, []notifications.Intent{{ + Reason: notifications.ReasonPostReply, RecipientDID: recipientDID, ActorDID: actorDID, + RecordURI: recordURI, RecordCID: "bafyrenotificationreply", + RootPostURI: "at://" + recipientDID + "/social.coves.community.postv2/root", + RecordCreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }}) + commitError := transaction.Commit() + + var storedCount int + require.NoError(t, db.QueryRowContext(ctx, + `SELECT count(*) FROM notifications WHERE record_uri = $1`, recordURI).Scan(&storedCount)) + require.Zero(t, storedCount, "a reply intent without a subject must not be stored with subject_uri = ''") + require.Error(t, applyError, "ApplyTx must bind an empty SubjectURI as NULL so the reply CHECK rejects it") + var postgresError *pq.Error + require.True(t, errors.As(applyError, &postgresError), "the returned error must wrap the Postgres error") + require.Equal(t, pq.ErrorCode("23514"), postgresError.Code) + require.Equal(t, "notifications_check", postgresError.Constraint, + "the per-reason shape CHECK, not the reason CHECK, must reject the intent") + require.Error(t, commitError, "the transaction whose intent violated the CHECK must not commit") +} + +func TestNotificationRepository_ApplyTx_StoresMentionWithoutSubjectAsNull(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + recipientDID := "did:plc:notificationrecipient" + uniqueID + actorDID := "did:plc:notificationactor" + uniqueID + createTestUser(t, db, "notificationrecipient"+uniqueID+".test", recipientDID) + recordURI := "at://" + actorDID + "/social.coves.community.comment/mention" + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, NewNotificationRepository(db).ApplyTx(ctx, transaction, []notifications.Intent{{ + Reason: notifications.ReasonMention, RecipientDID: recipientDID, ActorDID: actorDID, + RecordURI: recordURI, RecordCID: "bafyrenotificationmention", + RootPostURI: "at://" + recipientDID + "/social.coves.community.postv2/root", + RecordCreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }}), "a mention intent has no subject; its empty SubjectURI must satisfy the mention CHECK as NULL") + require.NoError(t, transaction.Commit()) + + var subjectURI sql.NullString + require.NoError(t, db.QueryRowContext(ctx, + `SELECT subject_uri FROM notifications WHERE record_uri = $1`, recordURI).Scan(&subjectURI)) + require.False(t, subjectURI.Valid, "an empty SubjectURI must be stored as NULL, got %q", subjectURI.String) +} diff --git a/internal/db/postgres/notification_retention_batch_test.go b/internal/db/postgres/notification_retention_batch_test.go new file mode 100644 index 0000000..f67f9e0 --- /dev/null +++ b/internal/db/postgres/notification_retention_batch_test.go @@ -0,0 +1,258 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func retentionBatchReplies(t *testing.T, db *sql.DB, recipient, prefix string, oldCount, recentCount int, oldAt, recentAt, newestAt time.Time) { + t.Helper() + _, err := db.Exec(`INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, sort_at) + SELECT $1, 'postReply', $2 || number::text, 'bafyretentionbatch', $1, $3, $3, + CASE WHEN number <= $4::integer THEN $5::timestamptz + WHEN number = $4::integer + $6::integer + 1 THEN $8::timestamptz ELSE $7::timestamptz END + FROM generate_series(1, $4::integer + $6::integer + 1) AS number`, recipient, prefix, + "at://"+recipient+"/social.coves.community.postv2/batch", oldCount, oldAt, recentCount, recentAt, newestAt) + require.NoError(t, err) +} + +func retentionBatchCount(t *testing.T, db *sql.DB, query string, args ...any) int { + t.Helper() + var count int + require.NoError(t, db.QueryRow(query, args...).Scan(&count)) + return count +} + +type retentionBatchFixture struct { + eligibleQuery string + eligibleArgs []any + controlsQuery string + controlsArgs []any + lockedID int64 +} + +func retentionBatchSetup(t *testing.T, db *sql.DB, sweep string, oldCount int) retentionBatchFixture { + t.Helper() + recipient := retentionUser(t, db) + prefix := "at://" + recipient + "/social.coves.community.comment/batch-" + now := time.Date(2026, time.December, 1, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + fixture := retentionBatchFixture{} + switch sweep { + case "read", "unread_cap": + oldAt := now.Add(-721 * time.Hour) + recentAt := now.Add(-719 * time.Hour) + newestAt := now.Add(time.Hour) + recentCount := 1 + if sweep == "read" { + retentionSeenAt(t, db, recipient, now) + } else { + retentionSeenAt(t, db, recipient, now.Add(-5000*time.Hour)) + oldAt = now.Add(-4800 * time.Hour) + recentAt = now.Add(-time.Hour) + newestAt = now + recentCount = 501 + } + retentionBatchReplies(t, db, recipient, prefix, oldCount, recentCount, oldAt, recentAt, newestAt) + fixture.eligibleQuery = `SELECT count(*) FROM notifications WHERE recipient_did = $1 AND record_uri LIKE $2 AND sort_at = $3` + fixture.eligibleArgs = []any{recipient, prefix + "%", oldAt} + fixture.controlsQuery = `SELECT count(*) FROM notifications WHERE recipient_did = $1 AND record_uri LIKE $2 AND sort_at <> $3` + fixture.controlsArgs = []any{recipient, prefix + "%", oldAt} + require.NoError(t, db.QueryRow(`SELECT id FROM notifications WHERE record_uri = $1`, prefix+"1").Scan(&fixture.lockedID)) + case "empty_groups": + subjectPrefix := "at://" + recipient + "/social.coves.community.postv2/batch-" + _, err := db.Exec(`INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri, sort_at) + SELECT $1, 'upvote', $2 || number::text, $2 || number::text, $3 + FROM generate_series(1, $4) AS number`, recipient, subjectPrefix, now, oldCount) + require.NoError(t, err) + votes := qualifyingUpvoteFixture{db: db} + ids := make(map[string]int64) + for _, label := range []string{"kept_first", "kept_second"} { + retentionRow(t, db, ids, label, recipient, "upvote", now) + votes.insertVote(t, "did:plc:"+testkit.UniqueID(t), + "at://"+recipient+"/social.coves.community.postv2/"+label, "up", now, false) + } + fixture.eligibleQuery = `SELECT count(*) FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri LIKE $2` + fixture.eligibleArgs = []any{recipient, subjectPrefix + "%"} + fixture.controlsQuery = `SELECT count(*) FROM notifications WHERE id = ANY($1)` + fixture.controlsArgs = []any{pq.Array([]int64{ids["kept_first"], ids["kept_second"]})} + require.NoError(t, db.QueryRow(`SELECT id FROM notifications WHERE recipient_did = $1 AND subject_uri = $2`, + recipient, subjectPrefix+"1").Scan(&fixture.lockedID)) + case "hidden_references": + // Every reference is unindexed, so each row reads hidden; only age + // (database time) separates the eligible rows from the two controls. + _, err := db.Exec(`INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + SELECT $1, 'postReply', $2 || CASE WHEN number <= $3::integer THEN 'old-' ELSE 'recent-' END || number::text, + 'bafyretentionbatch', $1, $4, $4, now(), + now() - CASE WHEN number <= $3::integer THEN interval '200 hours' ELSE interval '100 hours' END + FROM generate_series(1, $3::integer + 2) AS number`, + recipient, prefix, oldCount, "at://"+recipient+"/social.coves.community.postv2/batch") + require.NoError(t, err) + fixture.eligibleQuery = `SELECT count(*) FROM notifications WHERE recipient_did = $1 AND record_uri LIKE $2` + fixture.eligibleArgs = []any{recipient, prefix + "old-%"} + fixture.controlsQuery = `SELECT count(*) FROM notifications WHERE recipient_did = $1 AND record_uri LIKE $2` + fixture.controlsArgs = []any{recipient, prefix + "recent-%"} + require.NoError(t, db.QueryRow(`SELECT id FROM notifications WHERE record_uri = $1`, prefix+"old-1").Scan(&fixture.lockedID)) + default: + t.Fatalf("unknown retention sweep %q", sweep) + } + return fixture +} + +func retentionBatchSweeps() []struct { + name string + sweep func(notifications.RetentionSweeper, context.Context) (int64, error) +} { + return []struct { + name string + sweep func(notifications.RetentionSweeper, context.Context) (int64, error) + }{ + {"read", notifications.RetentionSweeper.SweepReadNotifications}, + {"unread_cap", notifications.RetentionSweeper.SweepUnreadOverflow}, + {"empty_groups", notifications.RetentionSweeper.SweepEmptyUpvoteGroups}, + {"hidden_references", notifications.RetentionSweeper.SweepHiddenReferenceNotifications}, + } +} + +func TestNotificationRetention_PerStatementBatchCap(t *testing.T) { + for _, test := range retentionBatchSweeps() { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + fixture := retentionBatchSetup(t, db, test.name, 10001) + controls := 2 + if test.name == "unread_cap" { + controls = 502 + } + require.Equal(t, 10001, retentionBatchCount(t, db, fixture.eligibleQuery, fixture.eligibleArgs...)) + require.Equal(t, controls, retentionBatchCount(t, db, fixture.controlsQuery, fixture.controlsArgs...)) + sweeper := retentionSweeper(t, db) + + deleted, err := test.sweep(sweeper, context.Background()) + require.NoError(t, err) + require.EqualValues(t, 10000, deleted, "one statement must delete at most 10000 eligible rows") + require.Equal(t, 1, retentionBatchCount(t, db, fixture.eligibleQuery, fixture.eligibleArgs...)) + require.Equal(t, controls, retentionBatchCount(t, db, fixture.controlsQuery, fixture.controlsArgs...)) + + deleted, err = test.sweep(sweeper, context.Background()) + require.NoError(t, err) + require.EqualValues(t, 1, deleted) + require.Zero(t, retentionBatchCount(t, db, fixture.eligibleQuery, fixture.eligibleArgs...)) + require.Equal(t, controls, retentionBatchCount(t, db, fixture.controlsQuery, fixture.controlsArgs...)) + }) + } +} + +func TestNotificationRetention_SkipsLockedRows(t *testing.T) { + for _, test := range retentionBatchSweeps() { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + fixture := retentionBatchSetup(t, db, test.name, 2) + controls := 2 + if test.name == "unread_cap" { + controls = 502 + } + require.Equal(t, 2, retentionBatchCount(t, db, fixture.eligibleQuery, fixture.eligibleArgs...)) + require.Equal(t, controls, retentionBatchCount(t, db, fixture.controlsQuery, fixture.controlsArgs...)) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + transaction, _ := notificationRaceTransaction(t, db, ctx) + var lockedID int64 + require.NoError(t, transaction.QueryRowContext(ctx, + `SELECT id FROM notifications WHERE id = $1 FOR UPDATE`, fixture.lockedID).Scan(&lockedID)) + require.Equal(t, fixture.lockedID, lockedID) + + deleted, err := test.sweep(retentionSweeper(t, db), ctx) + require.NoError(t, err, "sweep must not wait for another writer's notification row lock") + require.EqualValues(t, 1, deleted, "the unlocked eligible row must be deleted") + require.Equal(t, 1, retentionBatchCount(t, db, fixture.eligibleQuery, fixture.eligibleArgs...)) + require.Equal(t, controls, retentionBatchCount(t, db, fixture.controlsQuery, fixture.controlsArgs...)) + var survivor int64 + require.NoError(t, db.QueryRowContext(ctx, `SELECT id FROM notifications WHERE id = $1`, lockedID).Scan(&survivor)) + require.Equal(t, lockedID, survivor) + }) + } +} + +func TestNotificationRetention_EmptyGroupRechecksAfterCandidateLocks(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + recipient := retentionUser(t, db) + voter := "did:plc:" + testkit.UniqueID(t) + now := time.Date(2026, time.December, 1, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + ids := make(map[string]int64) + retentionRow(t, db, ids, "blocked_group", recipient, "upvote", now) + retentionRow(t, db, ids, "empty_group", recipient, "upvote", now) + votes := qualifyingUpvoteFixture{db: db} + votes.insertBlock(t, recipient, voter) + votes.insertVote(t, voter, "at://"+recipient+"/social.coves.community.postv2/blocked_group", "up", now, false) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + holder, holderPID := notificationRaceTransaction(t, db, ctx) + _, err := holder.ExecContext(ctx, `LOCK TABLE notifications IN SHARE MODE`) + require.NoError(t, err) + + type result struct { + deleted int64 + err error + } + results := make(chan result, 1) + sweeper := retentionSweeper(t, db) + go func() { + deleted, err := sweeper.SweepEmptyUpvoteGroups(ctx) + results <- result{deleted, err} + }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + select { + case got := <-results: + return false, fmt.Errorf("sweep returned (%d, %v) before its DELETE waited for the table lock", got.deleted, got.err) + default: + } + var waiterPID int + err := db.QueryRowContext(ctx, `SELECT pid FROM pg_locks + WHERE relation = 'notifications'::regclass AND locktype = 'relation' + AND mode = 'RowExclusiveLock' AND NOT granted + AND $1 = ANY(pg_blocking_pids(pid)) LIMIT 1`, holderPID).Scan(&waiterPID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("the sweep's DELETE waiting for a relation lock after locking its candidates")) + + _, err = holder.ExecContext(ctx, `SAVEPOINT retention_probe`) + require.NoError(t, err) + var id int64 + err = holder.QueryRowContext(ctx, `SELECT id FROM notifications WHERE id = $1 FOR UPDATE NOWAIT`, ids["blocked_group"]).Scan(&id) + var lockError *pq.Error + require.ErrorAs(t, err, &lockError, "the sweep must already hold the blocked group's row lock") + require.Equal(t, pq.ErrorCode("55P03"), lockError.Code, "the candidate must be locked before DELETE starts") + _, err = holder.ExecContext(ctx, `ROLLBACK TO SAVEPOINT retention_probe`) + require.NoError(t, err) + _, err = holder.ExecContext(ctx, `DELETE FROM user_blocks WHERE blocker_did = $1 AND blocked_did = $2`, recipient, voter) + require.NoError(t, err) + require.NoError(t, holder.Commit()) + + select { + case got := <-results: + require.NoError(t, got.err) + require.EqualValues(t, 1, got.deleted, "the newly qualifying group must be kept while the still-empty group is deleted") + case <-ctx.Done(): + t.Fatalf("sweep did not finish after the holder committed: %v", ctx.Err()) + } + require.Equal(t, map[string]bool{"blocked_group": true}, retentionSurvivors(t, db, ids)) +} diff --git a/internal/db/postgres/notification_retention_test.go b/internal/db/postgres/notification_retention_test.go new file mode 100644 index 0000000..ed2d810 --- /dev/null +++ b/internal/db/postgres/notification_retention_test.go @@ -0,0 +1,452 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "strings" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func retentionSweeper(t *testing.T, db *sql.DB) notifications.RetentionSweeper { + t.Helper() + sweeper, ok := NewNotificationRepository(db).(notifications.RetentionSweeper) + require.True(t, ok, "notification repository must implement RetentionSweeper") + return sweeper +} + +func retentionUser(t *testing.T, db *sql.DB) string { + t.Helper() + uniqueID := testkit.UniqueID(t) + did := "did:plc:" + uniqueID + createTestUser(t, db, uniqueID+".test", did) + return did +} + +func retentionSeenAt(t *testing.T, db *sql.DB, recipient string, seenAt any) { + t.Helper() + _, err := db.Exec(`INSERT INTO notification_state (did, seen_at) VALUES ($1, $2)`, recipient, seenAt) + require.NoError(t, err) +} + +func retentionRow(t *testing.T, db *sql.DB, ids map[string]int64, label, recipient, reason string, sortAt time.Time) { + t.Helper() + recordURI := "at://" + recipient + "/social.coves.community.comment/" + label + subjectURI := "at://" + recipient + "/social.coves.community.postv2/" + label + var id int64 + if reason == "upvote" { + require.NoError(t, db.QueryRow(`INSERT INTO notifications + (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $2, $3) RETURNING id`, recipient, subjectURI, sortAt).Scan(&id)) + } else { + require.NoError(t, db.QueryRow(`INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, 'postReply', $2, 'bafyretentionreply', $1, $3, $3, $4, $4) RETURNING id`, + recipient, recordURI, subjectURI, sortAt).Scan(&id)) + } + ids[label] = id +} + +func retentionSurvivors(t *testing.T, db *sql.DB, ids map[string]int64) map[string]bool { + t.Helper() + labels := make(map[int64]string, len(ids)) + for label, id := range ids { + labels[id] = label + } + rows, err := db.Query(`SELECT id FROM notifications`) + require.NoError(t, err) + defer rows.Close() + survivors := make(map[string]bool) + for rows.Next() { + var id int64 + require.NoError(t, rows.Scan(&id)) + label, ok := labels[id] + require.True(t, ok, "unexpected notification id %d", id) + survivors[label] = true + } + require.NoError(t, rows.Err()) + return survivors +} + +func retentionNewYorkTimezone(t *testing.T, db *sql.DB) { + t.Helper() + var database string + require.NoError(t, db.QueryRow(`SELECT current_database()`).Scan(&database)) + _, err := db.Exec("ALTER DATABASE " + pq.QuoteIdentifier(database) + " SET timezone = 'America/New_York'") + require.NoError(t, err) + db.SetMaxIdleConns(0) + connection, err := db.Conn(context.Background()) + require.NoError(t, err) + defer connection.Close() + var timezone string + require.NoError(t, connection.QueryRowContext(context.Background(), `SHOW timezone`).Scan(&timezone)) + require.Equal(t, "America/New_York", timezone, "new connections must use the DST-crossing timezone") +} + +func TestNotificationRetention_ReadSeenAtUsesExactHoursAndEachRecipient(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + retentionNewYorkTimezone(t, db) + userA := retentionUser(t, db) + userB := retentionUser(t, db) + seenAt := time.Date(2026, time.November, 20, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + retentionSeenAt(t, db, userA, seenAt) + retentionSeenAt(t, db, userB, seenAt.Add(-960*time.Hour)) + ids := make(map[string]int64) + retentionRow(t, db, ids, "a_boundary", userA, "postReply", seenAt.Add(-720*time.Hour)) + retentionRow(t, db, ids, "a_old_group", userA, "upvote", seenAt.Add(-720*time.Hour-time.Microsecond)) + retentionRow(t, db, ids, "a_old_reply", userA, "postReply", seenAt.Add(-720*time.Hour-time.Microsecond)) + retentionRow(t, db, ids, "a_unread", userA, "postReply", seenAt.Add(time.Hour)) + retentionRow(t, db, ids, "b_unread", userB, "postReply", seenAt.Add(-720*time.Hour-time.Microsecond)) + retentionRow(t, db, ids, "b_old", userB, "postReply", seenAt.Add(-1680*time.Hour-time.Microsecond)) + + deleted, err := retentionSweeper(t, db).SweepReadNotifications(context.Background()) + require.NoError(t, err) + require.Equal(t, map[string]bool{ + "a_boundary": true, "a_unread": true, "b_unread": true, + }, retentionSurvivors(t, db, ids)) + require.EqualValues(t, 3, deleted) +} + +func TestNotificationRetention_ReadNullOrMissingStateUsesRecipientNewest(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + userC := retentionUser(t, db) + userD := retentionUser(t, db) + retentionSeenAt(t, db, userC, nil) + newestC := time.Date(2026, time.November, 20, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + newestD := newestC.Add(240 * time.Hour) + shared := newestD.Add(-720*time.Hour - time.Microsecond) + ids := make(map[string]int64) + retentionRow(t, db, ids, "c_boundary", userC, "postReply", newestC.Add(-720*time.Hour)) + retentionRow(t, db, ids, "c_old", userC, "postReply", newestC.Add(-720*time.Hour-time.Microsecond)) + retentionRow(t, db, ids, "c_newest", userC, "postReply", newestC) + retentionRow(t, db, ids, "c_shared", userC, "postReply", shared) + retentionRow(t, db, ids, "d_boundary", userD, "postReply", newestD.Add(-720*time.Hour)) + retentionRow(t, db, ids, "d_old", userD, "postReply", newestD.Add(-720*time.Hour-time.Microsecond)) + retentionRow(t, db, ids, "d_newest", userD, "postReply", newestD) + retentionRow(t, db, ids, "d_shared", userD, "postReply", shared) + + deleted, err := retentionSweeper(t, db).SweepReadNotifications(context.Background()) + require.NoError(t, err) + require.Equal(t, map[string]bool{ + "c_boundary": true, "c_newest": true, "c_shared": true, + "d_boundary": true, "d_newest": true, + }, retentionSurvivors(t, db, ids)) + require.EqualValues(t, 3, deleted) +} + +// All bulk notifications have distinct record URIs; their labels are recorded +// from RETURNING so the survivor assertion identifies individual old rows. +func retentionBulkReplies(t *testing.T, db *sql.DB, ids map[string]int64, labelPrefix, recipient string, + total, oldCount int, oldAt time.Time, boundaryIndex int, boundaryAt time.Time, + pastBoundaryIndex int, pastBoundaryAt, recentAt, newestAt time.Time, +) { + t.Helper() + uriPrefix := "at://" + recipient + "/social.coves.community.comment/" + labelPrefix + "-" + rows, err := db.Query(`INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, sort_at) + SELECT $1, 'postReply', $2 || number::text, 'bafyretentionbulk', $1, $3, $3, + CASE WHEN number <= $5 THEN $6::timestamptz + WHEN number = $7 THEN $8::timestamptz + WHEN number = $9 THEN $10::timestamptz + WHEN number = $4 THEN $12::timestamptz ELSE $11::timestamptz END + FROM generate_series(1, $4) AS number RETURNING id, record_uri`, + recipient, uriPrefix, "at://"+recipient+"/social.coves.community.postv2/retention", + total, oldCount, oldAt, boundaryIndex, boundaryAt, pastBoundaryIndex, pastBoundaryAt, recentAt, newestAt) + require.NoError(t, err) + defer rows.Close() + for rows.Next() { + var id int64 + var uri string + require.NoError(t, rows.Scan(&id, &uri)) + ids[labelPrefix+"/"+strings.TrimPrefix(uri, uriPrefix)] = id + } + require.NoError(t, rows.Err()) + require.Len(t, ids, total, "one labelled id per bulk row") +} + +func TestNotificationRetention_UnreadOverflowUsesStrictCapAndExactHours(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + retentionNewYorkTimezone(t, db) + newest := time.Date(2026, time.December, 1, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + seenAt := newest.Add(-4800 * time.Hour) + userE := retentionUser(t, db) + userF := retentionUser(t, db) + userG := retentionUser(t, db) + userH := retentionUser(t, db) + userI := retentionUser(t, db) + retentionSeenAt(t, db, userE, seenAt) + retentionSeenAt(t, db, userF, seenAt) + retentionSeenAt(t, db, userG, nil) + ids := make(map[string]int64) + expected := make(map[string]bool) + addBulk := func(label, recipient string, total, oldCount int, oldAt time.Time, + boundaryIndex int, boundaryAt time.Time, pastBoundaryIndex int, pastBoundaryAt time.Time, + ) { + t.Helper() + bulk := make(map[string]int64) + retentionBulkReplies(t, db, bulk, label, recipient, total, oldCount, oldAt, + boundaryIndex, boundaryAt, pastBoundaryIndex, pastBoundaryAt, newest.Add(-time.Hour), newest) + for name, id := range bulk { + ids[name] = id + expected[name] = true + } + } + addBulk("e", userE, 500, 2, newest.Add(-4560*time.Hour), -1, newest, -1, newest) + retentionRow(t, db, ids, "e_at_seen", userE, "postReply", seenAt) + expected["e_at_seen"] = true + addBulk("f", userF, 500, 1, newest.Add(-4560*time.Hour), 2, newest.Add(-4320*time.Hour), + 3, newest.Add(-4320*time.Hour-time.Microsecond)) + retentionRow(t, db, ids, "f_at_seen", userF, "postReply", seenAt) + retentionRow(t, db, ids, "f_before_seen", userF, "postReply", seenAt.Add(-time.Hour)) + // More than 180 days before the table-wide newest row (user I's) but within + // 180 days of F's own newest row, so the window must be per recipient. + retentionRow(t, db, ids, "f_within_own_window", userF, "postReply", newest.Add(-4000*time.Hour)) + expected["f_at_seen"] = true + expected["f_before_seen"] = true + expected["f_within_own_window"] = true + delete(expected, "f/1") + delete(expected, "f/3") + addBulk("g", userG, 600, 50, newest.Add(-4560*time.Hour), -1, newest, -1, newest) + addBulk("h", userH, 600, 50, newest.Add(-4560*time.Hour), -1, newest, -1, newest) + retentionRow(t, db, ids, "i_table_newest", userI, "postReply", newest.Add(1000*time.Hour)) + expected["i_table_newest"] = true + + unreadCount := func(recipient string) int { + t.Helper() + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM notifications n + JOIN notification_state state ON state.did = n.recipient_did + WHERE n.recipient_did = $1 AND n.sort_at > state.seen_at`, recipient).Scan(&count)) + return count + } + require.Equal(t, 500, unreadCount(userE), "E must sit exactly at the unread cap") + require.Equal(t, 501, unreadCount(userF), "F must be exactly one over the unread cap") + + deleted, err := retentionSweeper(t, db).SweepUnreadOverflow(context.Background()) + require.NoError(t, err) + survivors := retentionSurvivors(t, db, ids) + require.False(t, survivors["f/1"], "F's 190-day-old unread row must be deleted") + require.False(t, survivors["f/3"], "F's unread row one microsecond beyond the cutoff must be deleted") + require.True(t, survivors["f_within_own_window"], + "F's unread row within 180 days of F's own newest row must survive even when another recipient has newer rows") + require.Equal(t, expected, survivors, "only F's two old unread rows should be deleted") + require.EqualValues(t, 2, deleted) +} + +func TestNotificationRetention_EmptyUpvoteGroupsRequireQualifyingVoterOnSameSubjectAndRecipient(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + first := retentionUser(t, db) + second := retentionUser(t, db) + votes := qualifyingUpvoteFixture{db: db} + now := time.Date(2026, time.November, 20, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond) + ids := make(map[string]int64) + groupWithRoot := func(label, recipient, subject, rootPost string) { + t.Helper() + var id int64 + require.NoError(t, db.QueryRow(`INSERT INTO notifications + (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $3, $4) RETURNING id`, recipient, subject, rootPost, now).Scan(&id)) + ids[label] = id + } + group := func(label, recipient, subject string) { + t.Helper() + groupWithRoot(label, recipient, subject, subject) + } + subject := func(label string) string { + return "at://" + first + "/social.coves.community.postv2/" + label + } + voter := func() string { return "did:plc:" + testkit.UniqueID(t) } + retracted, erased, blockedByRecipient, blockingRecipient, mixed, different, sharedVoter := voter(), voter(), voter(), voter(), voter(), voter(), voter() + commentVoter, rootPostVoter := voter(), voter() + votes.insertVote(t, retracted, subject("retracted"), "up", now, true) + votes.insertVote(t, erased, subject("erased"), "up", now, false) + _, err := db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, erased) + require.NoError(t, err) + votes.insertBlock(t, first, blockedByRecipient) + votes.insertVote(t, blockedByRecipient, subject("blocked_by_recipient"), "up", now, false) + votes.insertBlock(t, blockingRecipient, first) + votes.insertVote(t, blockingRecipient, subject("blocking_recipient"), "up", now, false) + votes.insertVote(t, first, subject("self"), "up", now, false) + votes.insertVote(t, different, subject("different_vote"), "up", now, false) + votes.insertVote(t, mixed, subject("mixed"), "up", now, false) + votes.insertVote(t, blockedByRecipient, subject("mixed"), "up", now, false) + votes.insertVote(t, sharedVoter, subject("shared"), "up", now, false) + votes.insertBlock(t, second, sharedVoter) + for _, label := range []string{"retracted", "erased", "blocked_by_recipient", "blocking_recipient", "self", "different_group", "mixed"} { + group(label, first, subject(label)) + } + group("shared_first", first, subject("shared")) + group("shared_second", second, subject("shared")) + retentionRow(t, db, ids, "reply_without_votes", first, "postReply", now) + // Comment groups are judged by votes on the comment, never on its root post. + comment := func(label string) string { + return "at://" + first + "/social.coves.community.comment/" + label + } + votes.insertVote(t, commentVoter, comment("comment_voted"), "up", now, false) + groupWithRoot("comment_voted", first, comment("comment_voted"), subject("comment_voted_root")) + votes.insertVote(t, rootPostVoter, subject("comment_unvoted_root"), "up", now, false) + groupWithRoot("comment_unvoted", first, comment("comment_unvoted"), subject("comment_unvoted_root")) + + deleted, err := retentionSweeper(t, db).SweepEmptyUpvoteGroups(context.Background()) + require.NoError(t, err) + require.Equal(t, map[string]bool{ + "mixed": true, "shared_first": true, "reply_without_votes": true, "comment_voted": true, + }, retentionSurvivors(t, db, ids)) + require.EqualValues(t, 8, deleted) +} + +// A row is swept once a required reference has read hidden for longer than the +// window. Placeholders, and rows hidden only by blocks, preferences or the +// upvote alive rule, are never swept. Each shape returns the notification's +// reason, record, subject and root; every other reference is indexed and live. +func TestNotificationRetention_HiddenReferencesSweptAfterWindow(t *testing.T) { + t.Parallel() + type shape func(t *testing.T, f *unreadVisibilityFixture) (reason, record, subject, root string) + subjectPost := func(prepare func(t *testing.T, f *unreadVisibilityFixture) string) shape { + return func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + return "postReply", f.comment(t, f.root), prepare(t, f), f.root + } + } + visibleReply := func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + return "postReply", f.comment(t, f.root), f.root, f.root + } + hidden := []struct { + name string + shape shape + }{ + {"root never-public post", func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + root := f.post(t, posts.AdmissionStatusPending, false) + return "commentReply", f.comment(t, root), f.comment(t, root), root + }}, + {"record never-public mention post", func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + return "mention", f.post(t, posts.AdmissionStatusPending, false), "", f.root + }}, + {"record unindexed reply comment", func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + return "postReply", "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID(), f.root, f.root + }}, + {"subject never-public post", subjectPost(func(t *testing.T, f *unreadVisibilityFixture) string { + return f.post(t, posts.AdmissionStatusPending, false) + })}, + {"subject accepted post with mismatched CID", subjectPost(func(t *testing.T, f *unreadVisibilityFixture) string { + uri := f.post(t, "", false) + seedVisibilityAdmissionDriftedCID(t, f.db, f.community, uri) + return uri + })}, + {"subject removed by community without withdrawal marker", subjectPost(func(t *testing.T, f *unreadVisibilityFixture) string { + return f.post(t, posts.AdmissionStatusRemoved, false) + })}, + {"subject author-deleted without withdrawal marker", subjectPost(func(t *testing.T, f *unreadVisibilityFixture) string { + uri := f.post(t, posts.AdmissionStatusAccepted, false) + f.deletePost(t, uri) + return uri + })}, + {"subject unindexed comment", func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + return "commentReply", f.comment(t, f.root), "at://" + f.recipient + "/social.coves.community.comment/" + testkit.TID(), f.root + }}, + {"record in unsupported collection", func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + return "mention", "at://" + f.actor + "/app.bsky.feed.post/" + testkit.TID(), "", f.root + }}, + } + type row struct { + name string + shape shape + ageHours int + counted bool // CountUnread, as a fixture check of the shape's read-time visibility + swept bool + // neverSeen checks the fixture precondition that the recipient has no + // notification_state row, so no seen_at or read-retention rule applies. + neverSeen bool + } + var rows []row + for _, h := range hidden { + rows = append(rows, + row{name: h.name + " past the window", shape: h.shape, ageHours: 200, swept: true}, + row{name: h.name + " within the window", shape: h.shape, ageHours: 100}) + } + neverPublicSubject := subjectPost(func(t *testing.T, f *unreadVisibilityFixture) string { + return f.post(t, posts.AdmissionStatusPending, false) + }) + rows = append(rows, + row{name: "never-seen recipient hidden subject far past the window", shape: neverPublicSubject, ageHours: 400, swept: true, neverSeen: true}, + row{name: "never-seen recipient hidden subject at the oldest age", shape: neverPublicSubject, ageHours: 2000, swept: true, neverSeen: true}) + rows = append(rows, + row{name: "author-deleted placeholder", ageHours: 200, counted: true, shape: subjectPost(func(t *testing.T, f *unreadVisibilityFixture) string { + uri := f.post(t, posts.AdmissionStatusAccepted, false) + f.deletePost(t, uri) + seedWithdrawalMarker(t, f.db, uri, "authorDelete", nil) + return uri + })}, + row{name: "deleted comment placeholder", ageHours: 200, counted: true, shape: func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + subject := f.comment(t, f.root) + f.deleteComment(t, subject) + return "commentReply", f.comment(t, f.root), subject, f.root + }}, + row{name: "community-removed placeholder", ageHours: 200, counted: true, shape: subjectPost(func(t *testing.T, f *unreadVisibilityFixture) string { + uri := f.post(t, posts.AdmissionStatusRemoved, false) + seedWithdrawalMarker(t, f.db, uri, "communityWithdrawal", "3lqqqqqqqqqq1") + return uri + })}, + row{name: "server-admin-removed placeholder", ageHours: 200, counted: true, shape: subjectPost(func(t *testing.T, f *unreadVisibilityFixture) string { + uri := f.post(t, posts.AdmissionStatusAccepted, false) + seedModerationDecision(t, f.db, uri, "removal", "", true) + return uri + })}, + row{name: "hidden only by a block", ageHours: 200, shape: func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + f.insertBlock(t, f.recipient, f.actor) + return visibleReply(t, f) + }}, + row{name: "hidden only by a disabled preference", ageHours: 200, shape: func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + retentionSeenAt(t, f.db, f.recipient, nil) + f.setDisabledReasons(t, f.recipient, []string{"postReply"}) + return visibleReply(t, f) + }}, + row{name: "hidden only by a dead upvote group", ageHours: 200, shape: func(t *testing.T, f *unreadVisibilityFixture) (string, string, string, string) { + return "upvote", "", f.root, f.root + }}, + row{name: "fully visible", ageHours: 200, counted: true, shape: visibleReply}, + ) + for _, tc := range rows { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + reason, record, subject, root := tc.shape(t, f) + f.listNotificationAt(t, f.recipient, f.actor, reason, record, subject, root, f.sortAt) + var id int64 + require.NoError(t, f.db.QueryRow(`UPDATE notifications SET sort_at = now() - make_interval(hours => $2) + WHERE recipient_did = $1 RETURNING id`, f.recipient, tc.ageHours).Scan(&id)) + if tc.neverSeen { + require.Zero(t, retentionBatchCount(t, f.db, `SELECT count(*) FROM notification_state WHERE did = $1`, f.recipient), + "the recipient must never have set seen_at") + } + counted := 0 + if tc.counted { + counted = 1 + } + f.requireCount(t, counted) + + deleted, err := retentionSweeper(t, f.db).SweepHiddenReferenceNotifications(context.Background()) + require.NoError(t, err) + remaining := retentionBatchCount(t, f.db, `SELECT count(*) FROM notifications WHERE id = $1`, id) + if tc.swept { + require.EqualValues(t, 1, deleted, "the hidden row past the window must be swept") + require.Zero(t, remaining) + return + } + require.Zero(t, deleted) + require.Equal(t, 1, remaining, "the row must survive the hidden-reference sweep") + }) + } +} diff --git a/internal/db/postgres/notification_seen.go b/internal/db/postgres/notification_seen.go new file mode 100644 index 0000000..66337cb --- /dev/null +++ b/internal/db/postgres/notification_seen.go @@ -0,0 +1,25 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "Coves/internal/core/notifications" +) + +// UpdateSeen advances the recipient's seen time without changing preferences. +// seenAt is sent in UTC: datetime syntax allows zone offsets to ±23:59, but +// Postgres rejects offsets beyond ±15:59. +func (r *postgresNotificationRepo) UpdateSeen(ctx context.Context, did string, seenAt time.Time) error { + _, err := r.db.ExecContext(ctx, `INSERT INTO notification_state (did, seen_at) + VALUES ($1, LEAST($2::timestamptz, NOW())) + ON CONFLICT (did) DO UPDATE SET seen_at = GREATEST(notification_state.seen_at, LEAST(EXCLUDED.seen_at, NOW()))`, did, seenAt.UTC()) + if isStateAccountForeignKeyViolation(err) { + return notifications.ErrAccountNotIndexed + } + if err != nil { + return fmt.Errorf("update notification seen time: %w", err) + } + return nil +} diff --git a/internal/db/postgres/notification_seen_test.go b/internal/db/postgres/notification_seen_test.go new file mode 100644 index 0000000..5bdbee2 --- /dev/null +++ b/internal/db/postgres/notification_seen_test.go @@ -0,0 +1,124 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func notificationSeenState(t *testing.T, db *sql.DB, did string) (sql.NullTime, []string) { + t.Helper() + var seenAt sql.NullTime + var disabled []string + // The left join makes a missing state row observable as a NULL seen_at. + require.NoError(t, db.QueryRowContext(context.Background(), `SELECT state.seen_at, + COALESCE(state.disabled_reasons, ARRAY[]::text[]) + FROM users account LEFT JOIN notification_state state ON state.did = account.did + WHERE account.did = $1`, did).Scan(&seenAt, pq.Array(&disabled))) + return seenAt, disabled +} + +func TestNotificationSeen_UpdateSeenIsMonotonic(t *testing.T) { + t.Parallel() + instant := time.Date(2026, 9, 1, 12, 34, 56, 123456000, time.UTC) + for _, tc := range []struct { + name string + initial []string + updates []time.Time + wantSeen time.Time + wantOff []string + }{ + {"no state row", nil, []time.Time{instant}, instant, []string{}}, + {"preferences state with null seen at", []string{"mention", "upvote"}, + []time.Time{instant.Add(-time.Hour), instant}, instant, []string{"mention", "upvote"}}, + {"backwards update ignored", nil, []time.Time{instant, instant.Add(-time.Hour)}, instant, []string{}}, + {"later update advances", nil, []time.Time{instant.Add(-time.Hour), instant}, instant, []string{}}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + did := retentionUser(t, db) + if tc.initial != nil { + _, err := db.ExecContext(context.Background(), `INSERT INTO notification_state (did, disabled_reasons) + VALUES ($1, $2)`, did, pq.Array(tc.initial)) + require.NoError(t, err) + } + repo := NewNotificationRepository(db).(notifications.ReadRepository) + for _, seenAt := range tc.updates { + require.NoError(t, repo.UpdateSeen(context.Background(), did, seenAt)) + } + stored, disabled := notificationSeenState(t, db, did) + require.True(t, stored.Valid, "stored seen_at must be non-NULL") + require.True(t, stored.Time.UTC().Equal(tc.wantSeen), "stored seen_at = %s, want %s", stored.Time.UTC(), tc.wantSeen) + require.Equal(t, tc.wantOff, disabled, "disabled_reasons") + }) + } +} + +func TestNotificationSeen_FutureSeenAtIsClampedToServerTime(t *testing.T) { + t.Parallel() + instant := time.Date(2026, 9, 1, 12, 34, 56, 123456000, time.UTC) + for _, tc := range []struct { + name string + seenFirst bool + }{ + {"no state row", false}, + {"existing seen at", true}, + } { + t.Run(tc.name, func(t *testing.T) { + db := testkit.DB(t) + did := retentionUser(t, db) + repo := NewNotificationRepository(db).(notifications.ReadRepository) + if tc.seenFirst { + require.NoError(t, repo.UpdateSeen(context.Background(), did, instant)) + } + var serverNow, future time.Time + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT now(), now() + interval '1 hour'`).Scan(&serverNow, &future)) + require.NoError(t, repo.UpdateSeen(context.Background(), did, future)) + var serverAfter time.Time + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT clock_timestamp()`).Scan(&serverAfter)) + stored, _ := notificationSeenState(t, db, did) + require.True(t, stored.Valid, "stored seen_at must be non-NULL") + require.False(t, stored.Time.UTC().Before(serverNow.UTC()), "stored seen_at %s is before server now %s", stored.Time.UTC(), serverNow.UTC()) + require.False(t, stored.Time.UTC().After(serverAfter.UTC()), "stored seen_at %s is after server clock %s", stored.Time.UTC(), serverAfter.UTC()) + }) + } +} + +// countReplyAt indexes at sortAt and also sets record_created_at to sortAt; +// delay the record time explicitly so the count must use index time instead. +func (f *unreadVisibilityFixture) delayedReplyAt(t *testing.T, sortAt, recordCreatedAt time.Time) { + t.Helper() + record := f.countReplyAt(t, sortAt, f.root) + result, err := f.db.ExecContext(context.Background(), `UPDATE notifications + SET record_created_at = $3 WHERE recipient_did = $1 AND record_uri = $2`, + f.recipient, record, recordCreatedAt) + require.NoError(t, err) + updated, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, updated, "delayed reply notification must exist") +} + +func TestNotificationSeen_DelayedReplyAfterSeenAtIsUnread(t *testing.T) { + t.Parallel() + seenAt := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + f := newUnreadCountFixture(t) + repo := NewNotificationRepository(f.db).(notifications.ReadRepository) + require.NoError(t, repo.UpdateSeen(context.Background(), f.recipient, seenAt)) + f.countReplyAt(t, seenAt.Add(-time.Hour), f.root) + f.delayedReplyAt(t, seenAt.Add(time.Minute), seenAt.Add(-time.Hour)) + f.delayedReplyAt(t, seenAt.Add(2*time.Minute), seenAt.Add(-time.Hour)) + count, err := repo.CountUnread(context.Background(), f.recipient) + require.NoError(t, err) + require.Equal(t, 2, count, "delayed replies indexed after seen_at must be unread") +} diff --git a/internal/db/postgres/notification_sort_at_seen_race_test.go b/internal/db/postgres/notification_sort_at_seen_race_test.go new file mode 100644 index 0000000..db3f34a --- /dev/null +++ b/internal/db/postgres/notification_sort_at_seen_race_test.go @@ -0,0 +1,82 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +// An ingest transaction can start, wait on row locks, and write after the +// recipient has marked notifications seen. The written row was not visible when +// seen_at advanced, so it must stay unread however early the transaction began. +func TestNotificationSortAt_WriteCommittedAfterSeenAtIsUnread(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + setup func(t *testing.T, f *unreadVisibilityFixture) + write func(t *testing.T, f *unreadVisibilityFixture, repository notifications.Repository, transaction *sql.Tx) + }{ + { + name: "new reply inserted by ApplyTx", + setup: func(t *testing.T, f *unreadVisibilityFixture) {}, + write: func(t *testing.T, f *unreadVisibilityFixture, repository notifications.Repository, transaction *sql.Tx) { + require.NoError(t, repository.ApplyTx(context.Background(), transaction, []notifications.Intent{{ + Reason: notifications.ReasonPostReply, RecipientDID: f.recipient, ActorDID: f.actor, + RecordURI: f.comment(t, f.root), RecordCID: "bafyunreadrecord", + SubjectURI: f.root, RootPostURI: f.root, RecordCreatedAt: f.sortAt, + }})) + }, + }, + { + name: "existing upvote group bumped by ApplyUpvoteGroupTx", + setup: func(t *testing.T, f *unreadVisibilityFixture) { + f.insertVote(t, "did:plc:sortatvoter"+testkit.UniqueID(t), f.root, false) + f.notify(t, "upvote", "", f.root, f.root) + }, + write: func(t *testing.T, f *unreadVisibilityFixture, repository notifications.Repository, transaction *sql.Tx) { + require.NoError(t, repository.ApplyUpvoteGroupTx(context.Background(), transaction, notifications.UpvoteGroupIntent{ + Action: notifications.UpvoteGroupBump, RecipientDID: f.recipient, + SubjectURI: f.root, RootPostURI: f.root, + })) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + f := newUnreadCountFixture(t) + tc.setup(t, f) + repository := NewNotificationRepository(f.db) + + transaction, err := f.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + var transactionStart time.Time + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT now()`).Scan(&transactionStart)) + + // Poll the database clock until it is past the transaction start, so + // the seen time is after that start and not clamped to server now. + var seenAt time.Time + for attempt := 0; !seenAt.After(transactionStart); attempt++ { + require.Less(t, attempt, 100000, "database clock never passed the transaction start") + require.NoError(t, f.db.QueryRowContext(ctx, `SELECT clock_timestamp()`).Scan(&seenAt)) + } + require.NoError(t, repository.(notifications.ReadRepository).UpdateSeen(ctx, f.recipient, seenAt)) + + tc.write(t, f, repository, transaction) + require.NoError(t, transaction.Commit()) + + f.requireCount(t, 1) + page := f.listPage(t, f.recipient, "", 10) + require.Len(t, page.Notifications, 1) + require.False(t, page.Notifications[0].IsRead, "a notification committed after seen_at advanced must be unread") + }) + } +} diff --git a/internal/db/postgres/notification_time_lookups_test.go b/internal/db/postgres/notification_time_lookups_test.go new file mode 100644 index 0000000..901452e --- /dev/null +++ b/internal/db/postgres/notification_time_lookups_test.go @@ -0,0 +1,66 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationRepository_ActivatedAtReadsUncommittedTransactionValue(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + + activatedAt := time.Date(2026, time.September, 28, 12, 34, 56, 123456000, time.UTC) + result, err := transaction.ExecContext(ctx, `UPDATE notification_activation SET activated_at = $1`, activatedAt) + require.NoError(t, err) + rowsAffected, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rowsAffected, "the fixture must update the singleton activation row") + + actual, err := NewNotificationRepository(db).LookupsTx(transaction).ActivatedAt(ctx) + require.NoError(t, err) + require.Truef(t, actual.Equal(activatedAt), "ActivatedAt = %s, want uncommitted transaction value %s", actual, activatedAt) +} + +func TestNotificationRepository_ActivatedAtMissingRowReturnsError(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + + result, err := transaction.ExecContext(ctx, `DELETE FROM notification_activation`) + require.NoError(t, err) + rowsAffected, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rowsAffected, "the fixture must remove the singleton activation row") + + _, err = NewNotificationRepository(db).LookupsTx(transaction).ActivatedAt(ctx) + require.ErrorIs(t, err, ErrNotificationActivationMissing, "a missing activation row must not become a zero-time success") +} + +func TestNotificationRepository_IndexTimeIsTransactionNow(t *testing.T) { + t.Parallel() + ctx := context.Background() + db := testkit.DB(t) + transaction, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + + var transactionTime time.Time + require.NoError(t, transaction.QueryRowContext(ctx, `SELECT now()`).Scan(&transactionTime)) + actual, err := NewNotificationRepository(db).LookupsTx(transaction).IndexTime(ctx) + require.NoError(t, err) + require.Truef(t, actual.Equal(transactionTime), "IndexTime = %s, want transaction now() = %s", actual, transactionTime) +} diff --git a/internal/db/postgres/notification_unread_count_test.go b/internal/db/postgres/notification_unread_count_test.go new file mode 100644 index 0000000..b2e956e --- /dev/null +++ b/internal/db/postgres/notification_unread_count_test.go @@ -0,0 +1,276 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "testing" + "time" + + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Unlike newUnreadVisibilityFixture, these cases need to choose whether state +// and the visible control row exist at all. +func newUnreadCountFixture(t *testing.T) *unreadVisibilityFixture { + t.Helper() + db := testkit.DB(t) + f := &unreadVisibilityFixture{ + db: db, + recipient: retentionUser(t, db), + actor: "did:plc:unreadactor" + testkit.UniqueID(t), + community: visibilityCommunity(t, db, testkit.UniqueID(t)), + sortAt: time.Date(2026, time.September, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond), + } + f.root = f.post(t, posts.AdmissionStatusAccepted, false) + return f +} + +func (f *unreadVisibilityFixture) countReplyAt(t *testing.T, sortAt time.Time, root string) string { + t.Helper() + f.sortAt = sortAt.Truncate(time.Microsecond) + record := f.comment(t, root) + f.notify(t, "postReply", record, root, root) + return record +} + +func TestNotificationUnreadCount_NeverSeenUsesNewestVisible(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + stateRow bool + caseName string + want int + }{ + {"three visible/null state", true, "three visible", 1}, + {"three visible/no state", false, "three visible", 1}, + {"newest pending/null state", true, "newest pending", 1}, + {"newest pending/no state", false, "newest pending", 1}, + {"newest unindexed record/null state", true, "newest unindexed", 1}, + {"newest unindexed record/no state", false, "newest unindexed", 1}, + {"deleted comment placeholder/null state", true, "deleted comment", 1}, + {"deleted comment placeholder/no state", false, "deleted comment", 1}, + {"removed post placeholder/null state", true, "removed post", 1}, + {"removed post placeholder/no state", false, "removed post", 1}, + {"only hidden/null state", true, "only hidden", 0}, + {"only hidden/no state", false, "only hidden", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadCountFixture(t) + if tc.stateRow { + retentionSeenAt(t, f.db, f.recipient, nil) + } + base := f.sortAt + switch tc.caseName { + case "three visible": + for _, offset := range []time.Duration{0, time.Second, 2 * time.Second} { + f.countReplyAt(t, base.Add(offset), f.root) + } + case "newest pending": + f.countReplyAt(t, base, f.root) + pending := f.post(t, posts.AdmissionStatusPending, false) + f.countReplyAt(t, base.Add(time.Second), pending) + case "newest unindexed": + f.countReplyAt(t, base, f.root) + f.sortAt = base.Add(time.Second) + missing := "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + f.notify(t, "commentReply", missing, f.comment(t, f.root), f.root) + case "deleted comment": + pending := f.post(t, posts.AdmissionStatusPending, false) + f.countReplyAt(t, base, pending) + f.sortAt = base.Add(time.Second) + record := f.comment(t, f.root) + f.deleteComment(t, record) + f.notify(t, "commentReply", record, f.comment(t, f.root), f.root) + case "removed post": + removed := f.post(t, posts.AdmissionStatusRemoved, false) + seedWithdrawalMarker(t, f.db, removed, "communityWithdrawal", "3lqqqqqqqqqq1") + f.countReplyAt(t, base, removed) + pending := f.post(t, posts.AdmissionStatusPending, false) + f.countReplyAt(t, base.Add(time.Second), pending) + case "only hidden": + pending := f.post(t, posts.AdmissionStatusPending, false) + f.countReplyAt(t, base, pending) + f.countReplyAt(t, base.Add(time.Second), pending) + } + f.requireCount(t, tc.want) + }) + } +} + +func TestNotificationUnreadCount_SeenAtIsExclusive(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + seenAt := f.sortAt + retentionSeenAt(t, f.db, f.recipient, seenAt) + for _, offset := range []time.Duration{-time.Second, 0, time.Second, 2 * time.Second} { + f.countReplyAt(t, seenAt.Add(offset), f.root) + } + f.requireCount(t, 2) +} + +// Seed indexed record comments and matching notifications together; the caller +// chooses which rows point at the pending root. Every generated URI is distinct. +func insertUnreadCountRows(t *testing.T, db *sql.DB, recipient, actor, prefix, visibleRoot, hiddenRoot string, base time.Time, total int, hiddenMode string) { + t.Helper() + _, err := db.ExecContext(context.Background(), ` + WITH records AS MATERIALIZED ( + SELECT number, $2::text || number::text AS uri, + CASE WHEN $8 = 'all' OR ($8 = 'interleaved' AND (number > 9500 OR number % 19 NOT IN (0, 1))) + THEN $5::text ELSE $4::text END AS root, + $6::timestamptz + number * interval '1 microsecond' AS sort_at + FROM generate_series(1, $7) AS number + ), inserted AS ( + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + SELECT uri, 'bafycountcomment', number::text, $3, root, 'bafycountroot', root, 'bafycountroot', 'count comment', sort_at + FROM records RETURNING uri + ) + INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + SELECT $1, 'postReply', records.uri, 'bafycountcomment', $3, records.root, records.root, records.sort_at, records.sort_at + FROM records JOIN inserted ON inserted.uri = records.uri`, + recipient, prefix, actor, visibleRoot, hiddenRoot, base.Truncate(time.Microsecond), total, hiddenMode) + require.NoError(t, err) +} + +func TestNotificationUnreadCount_BoundedVisibleRows(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, hiddenMode string + visible, hidden, want int + }{ + {"150 visible", "none", 150, 0, 101}, + {"100 visible", "none", 100, 0, 100}, + {"150 newer hidden before 120 visible", "all", 120, 150, 101}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadCountFixture(t) + base := f.sortAt + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Second)) + prefix := "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + "-" + insertUnreadCountRows(t, f.db, f.recipient, f.actor, prefix, f.root, f.root, base, tc.visible, "none") + if tc.hidden > 0 { + pending := f.post(t, posts.AdmissionStatusPending, false) + insertUnreadCountRows(t, f.db, f.recipient, f.actor, prefix+"hidden-", f.root, pending, + base.Add(time.Second), tc.hidden, tc.hiddenMode) + } + f.requireCount(t, tc.want) + }) + } +} + +func unreadCountPlanHasIndex(node map[string]any, index string) bool { + if node["Index Name"] == index { + return true + } + children, _ := node["Plans"].([]any) + for _, child := range children { + plan, ok := child.(map[string]any) + if ok && unreadCountPlanHasIndex(plan, index) { + return true + } + } + return false +} + +func TestNotificationUnreadCount_RecipientSortIndex(t *testing.T) { + t.Parallel() + f := newUnreadCountFixture(t) + base := f.sortAt + retentionSeenAt(t, f.db, f.recipient, base.Add(-time.Second)) + pending := f.post(t, posts.AdmissionStatusPending, false) + prefix := "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + "-" + insertUnreadCountRows(t, f.db, f.recipient, f.actor, prefix, f.root, pending, base, 10000, "interleaved") + + otherPrefix := "did:plc:unreadother" + testkit.UniqueID(t) + _, err := f.db.ExecContext(context.Background(), `INSERT INTO users (did, handle, pds_url) + SELECT $1 || number::text, $2 || number::text || '.test', 'https://pds.test' + FROM generate_series(1, 20) AS number`, otherPrefix, testkit.UniqueID(t)) + require.NoError(t, err) + _, err = f.db.ExecContext(context.Background(), ` + WITH records AS MATERIALIZED ( + SELECT number, $1::text || number::text AS uri, + $2::text || (((number - 1) % 20) + 1)::text AS recipient, + $4::timestamptz + number * interval '1 microsecond' AS sort_at + FROM generate_series(1, 10000) AS number + ), inserted AS ( + INSERT INTO comments (uri, cid, rkey, commenter_did, root_uri, root_cid, parent_uri, parent_cid, content, created_at) + SELECT uri, 'bafycountcomment', number::text, $3, $5, 'bafycountroot', $5, 'bafycountroot', 'count comment', sort_at + FROM records RETURNING uri + ) + INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + SELECT records.recipient, 'postReply', records.uri, 'bafycountcomment', $3, $5, $5, records.sort_at, records.sort_at + FROM records JOIN inserted ON inserted.uri = records.uri`, + prefix+"other-", otherPrefix, f.actor, base, f.root) + require.NoError(t, err) + + for _, table := range []string{"notifications", "notification_state", "posts", "community_post_admissions", "comments", "notification_public_post_withdrawals"} { + _, err := f.db.ExecContext(context.Background(), "ANALYZE "+table) + require.NoError(t, err, "analyzing %s", table) + } + var raw []byte + require.NoError(t, f.db.QueryRowContext(context.Background(), "EXPLAIN (FORMAT JSON) "+countUnreadNotificationsSQL, f.recipient).Scan(&raw)) + var plans []struct { + Plan map[string]any `json:"Plan"` + } + require.NoError(t, json.Unmarshal(raw, &plans)) + require.Len(t, plans, 1) + assert.True(t, unreadCountPlanHasIndex(plans[0].Plan, "idx_notifications_recipient_sort"), "EXPLAIN plan: %s", raw) + f.requireCount(t, 101) +} + +func unreadCountPlanIndexNode(node map[string]any, index string) map[string]any { + if node["Index Name"] == index { + return node + } + children, _ := node["Plans"].([]any) + for _, child := range children { + if plan, ok := child.(map[string]any); ok { + if found := unreadCountPlanIndexNode(plan, index); found != nil { + return found + } + } + } + return nil +} + +// A recipient with a long read history must not walk it on every badge poll: +// the recipient index scan has to stop at the seen_at boundary. +func TestNotificationUnreadCount_SeenAtBoundsRecipientIndexScan(t *testing.T) { + t.Parallel() + const readRows, unreadRows = 10000, 5 + f := newUnreadCountFixture(t) + base := f.sortAt + seenAt := base.Add(readRows * time.Microsecond) + retentionSeenAt(t, f.db, f.recipient, seenAt) + prefix := "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + "-" + insertUnreadCountRows(t, f.db, f.recipient, f.actor, prefix, f.root, f.root, base, readRows, "none") + insertUnreadCountRows(t, f.db, f.recipient, f.actor, prefix+"unread-", f.root, f.root, seenAt, unreadRows, "none") + + for _, table := range []string{"notifications", "notification_state", "posts", "community_post_admissions", "comments", "notification_public_post_withdrawals"} { + _, err := f.db.ExecContext(context.Background(), "ANALYZE "+table) + require.NoError(t, err, "analyzing %s", table) + } + var raw []byte + require.NoError(t, f.db.QueryRowContext(context.Background(), "EXPLAIN (ANALYZE, FORMAT JSON) "+countUnreadNotificationsSQL, f.recipient).Scan(&raw)) + var plans []struct { + Plan map[string]any `json:"Plan"` + } + require.NoError(t, json.Unmarshal(raw, &plans)) + require.Len(t, plans, 1) + node := unreadCountPlanIndexNode(plans[0].Plan, "idx_notifications_recipient_sort") + require.NotNil(t, node, "EXPLAIN plan: %s", raw) + indexCond, _ := node["Index Cond"].(string) + assert.Contains(t, indexCond, "sort_at", "EXPLAIN plan: %s", raw) + actualRows, _ := node["Actual Rows"].(float64) + actualLoops, _ := node["Actual Loops"].(float64) + assert.LessOrEqual(t, actualRows*actualLoops, float64(unreadRows), "rows read from recipient index; EXPLAIN plan: %s", raw) + f.requireCount(t, unreadRows) +} diff --git a/internal/db/postgres/notification_unread_rules_test.go b/internal/db/postgres/notification_unread_rules_test.go new file mode 100644 index 0000000..b354e11 --- /dev/null +++ b/internal/db/postgres/notification_unread_rules_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package postgres + +import ( + "context" + "testing" + + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func (f *unreadVisibilityFixture) notifyFromActor(t *testing.T, actor, reason, record, subject string) { + t.Helper() + var subjectURI any = subject + if reason == "mention" { + subjectURI = nil + } + _, err := f.db.ExecContext(context.Background(), `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, $2, $3, 'bafyunreadrecord', $4, $5, $6, $7, $7)`, + f.recipient, reason, record, actor, subjectURI, f.root, f.sortAt) + require.NoError(t, err) +} + +func (f *unreadVisibilityFixture) insertVote(t *testing.T, voter, subject string, retracted bool) { + t.Helper() + key := testkit.TID() + var deletedAt any + if retracted { + deletedAt = f.sortAt + } + _, err := f.db.ExecContext(context.Background(), `INSERT INTO votes + (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, deleted_at) + VALUES ($1, 'bafyunreadvote', $2, $3, $4, 'bafyunreadsubject', 'up', $5, $6)`, + "at://"+voter+"/social.coves.feed.vote/"+key, key, voter, subject, f.sortAt, deletedAt) + require.NoError(t, err) +} + +func (f *unreadVisibilityFixture) insertBlock(t *testing.T, blocker, blocked string) { + t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO user_blocks + (blocker_did, blocked_did, record_uri, record_cid) + VALUES ($1, $2, $3, 'bafyunreadblock')`, blocker, blocked, + "at://"+blocker+"/social.coves.actor.block/"+testkit.TID()) + require.NoError(t, err) +} + +func (f *unreadVisibilityFixture) eraseDID(t *testing.T, did string) { + t.Helper() + _, err := f.db.ExecContext(context.Background(), `INSERT INTO deleted_accounts (did) VALUES ($1)`, did) + require.NoError(t, err) +} + +func (f *unreadVisibilityFixture) setDisabledReasons(t *testing.T, did string, reasons []string) { + t.Helper() + result, err := f.db.ExecContext(context.Background(), `UPDATE notification_state + SET disabled_reasons = $2 WHERE did = $1`, did, pq.Array(reasons)) + require.NoError(t, err) + updated, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, updated) +} + +func TestNotificationUnreadRules_BlocksHideRecordRows(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, reason, blocker string + deletedRecord bool + want int + }{ + {"recipient blocks actor", "commentReply", "recipient", false, 1}, + {"actor blocks recipient", "mention", "actor", false, 1}, + {"recipient blocks actor of deleted comment", "commentReply", "recipient", true, 1}, + {"actor blocks recipient of deleted comment", "commentReply", "actor", true, 1}, + {"actor blocks unrelated DID", "commentReply", "third", false, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := newUnreadVisibilityFixture(t) + actor := "did:plc:unreadcase" + testkit.UniqueID(t) + record := seedActorComment(t, f.db, actor, f.root, testkit.TID(), f.sortAt) + if tc.deletedRecord { + f.deleteComment(t, record) + } + subject := seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), f.sortAt) + f.notifyFromActor(t, actor, tc.reason, record, subject) + switch tc.blocker { + case "recipient": + f.insertBlock(t, f.recipient, actor) + case "actor": + f.insertBlock(t, actor, f.recipient) + case "third": + f.insertBlock(t, actor, "did:plc:unreadthird"+testkit.UniqueID(t)) + } + f.requireCount(t, tc.want) + }) + } +} + +func TestNotificationUnreadRules_UpvoteGroupNeedsQualifyingVote(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, subjectKind, votes string + want int + }{ + {"blocked V1 plus live V2", "post", "liveSecond", 2}, + {"both voters blocked by recipient", "post", "blockedSecond", 1}, + {"V2 blocks recipient", "post", "secondBlocks", 1}, + {"V2 retracts upvote", "post", "retractedSecond", 1}, + {"V2 erased", "post", "erasedSecond", 1}, + {"only recipient self upvote", "post", "self", 1}, + {"vote only on another accepted post", "post", "otherSubject", 1}, + {"deleted recipient comment with live vote", "deletedComment", "live", 2}, + {"community withdrawn post with live vote", "removedPost", "live", 2}, + {"community withdrawn post with retracted vote", "removedPost", "retracted", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := newUnreadVisibilityFixture(t) + subject := f.post(t, posts.AdmissionStatusAccepted, false) + switch tc.subjectKind { + case "deletedComment": + subject = seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), f.sortAt) + f.deleteComment(t, subject) + case "removedPost": + subject = f.post(t, posts.AdmissionStatusRemoved, false) + seedWithdrawalMarker(t, f.db, subject, "communityWithdrawal", "3lqqqqqqqqqq1") + } + f.notify(t, "upvote", "", subject, f.root) + voterOne := "did:plc:unreadvoter" + testkit.UniqueID(t) + voterTwo := "did:plc:unreadvoter" + testkit.UniqueID(t) + switch tc.votes { + case "liveSecond", "blockedSecond", "secondBlocks", "retractedSecond", "erasedSecond": + f.insertVote(t, voterOne, subject, false) + f.insertBlock(t, f.recipient, voterOne) + f.insertVote(t, voterTwo, subject, tc.votes == "retractedSecond") + switch tc.votes { + case "blockedSecond": + f.insertBlock(t, f.recipient, voterTwo) + case "secondBlocks": + f.insertBlock(t, voterTwo, f.recipient) + case "erasedSecond": + f.eraseDID(t, voterTwo) + } + case "self": + f.insertVote(t, f.recipient, subject, false) + case "otherSubject": + f.insertVote(t, voterTwo, f.post(t, posts.AdmissionStatusAccepted, false), false) + case "live", "retracted": + f.insertVote(t, voterTwo, subject, tc.votes == "retracted") + } + f.requireCount(t, tc.want) + }) + } +} + +func TestNotificationUnreadRules_DisabledReasonsHideRows(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, scenario string + want int + }{ + {"recipient disables mention then enables it", "mentionToggle", 2}, + {"recipient disables commentReply including placeholder", "commentReply", 1}, + {"null element does not stop mention filter", "nullMention", 1}, + {"another user's preference does not apply", "otherUser", 2}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := newUnreadVisibilityFixture(t) + actor := "did:plc:unreadcase" + testkit.UniqueID(t) + record := seedActorComment(t, f.db, actor, f.root, testkit.TID(), f.sortAt) + f.notifyFromActor(t, actor, "mention", record, "") + switch tc.scenario { + case "mentionToggle": + f.notifyFromActor(t, actor, "postReply", + seedActorComment(t, f.db, actor, f.root, testkit.TID(), f.sortAt), f.root) + f.setDisabledReasons(t, f.recipient, []string{"mention"}) + case "commentReply": + placeholder := seedActorComment(t, f.db, actor, f.root, testkit.TID(), f.sortAt) + f.deleteComment(t, placeholder) + f.notifyFromActor(t, actor, "commentReply", placeholder, + seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), f.sortAt)) + f.setDisabledReasons(t, f.recipient, []string{"commentReply"}) + case "nullMention": + _, err := f.db.ExecContext(context.Background(), `UPDATE notification_state + SET disabled_reasons = ARRAY[NULL,'mention']::text[] WHERE did = $1`, f.recipient) + require.NoError(t, err) + case "otherUser": + otherUser := retentionUser(t, f.db) + retentionSeenAt(t, f.db, otherUser, f.sortAt) + f.setDisabledReasons(t, otherUser, []string{"mention"}) + f.setDisabledReasons(t, f.recipient, []string{}) + } + f.requireCount(t, tc.want) + if tc.scenario == "mentionToggle" { + f.setDisabledReasons(t, f.recipient, []string{}) + f.requireCount(t, 3) + } + }) + } +} diff --git a/internal/db/postgres/notification_unread_visibility_test.go b/internal/db/postgres/notification_unread_visibility_test.go new file mode 100644 index 0000000..6742c13 --- /dev/null +++ b/internal/db/postgres/notification_unread_visibility_test.go @@ -0,0 +1,356 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +// Each visibility case has its own recipient and an accepted commentReply control. +// Keeping the control in the same database makes hidden cases fail against a +// CountUnread implementation that always returns zero. +type unreadVisibilityFixture struct { + db *sql.DB + recipient string + actor string + community string + root string + sortAt time.Time +} + +func newUnreadVisibilityFixture(t *testing.T) *unreadVisibilityFixture { + t.Helper() + db := testkit.DB(t) + recipient := retentionUser(t, db) + fixture := &unreadVisibilityFixture{ + db: db, recipient: recipient, + actor: "did:plc:unreadactor" + testkit.UniqueID(t), + community: visibilityCommunity(t, db, testkit.UniqueID(t)), + sortAt: time.Date(2026, time.September, 30, 12, 0, 0, 0, time.UTC).Truncate(time.Microsecond), + } + retentionSeenAt(t, db, recipient, fixture.sortAt.Add(-time.Microsecond)) + fixture.root = fixture.post(t, posts.AdmissionStatusAccepted, false) + fixture.notify(t, "commentReply", fixture.comment(t, fixture.root), fixture.comment(t, fixture.root), fixture.root) + return fixture +} + +func (f *unreadVisibilityFixture) post(t *testing.T, status posts.AdmissionStatus, legacy bool) string { + t.Helper() + rkey := testkit.TID() + var uri string + if legacy { + uri = seedLegacyPost(t, f.db, f.community, f.recipient, rkey, "legacy root", f.sortAt) + } else { + uri = seedVisibilityPost(t, f.db, f.community, f.recipient, rkey, "notification post", f.sortAt) + if status != "" { + seedVisibilityAdmission(t, f.db, f.community, uri, status, "", "") + } + } + return uri +} + +func (f *unreadVisibilityFixture) comment(t *testing.T, root string) string { + t.Helper() + return seedActorComment(t, f.db, f.actor, root, testkit.TID(), f.sortAt) +} + +func (f *unreadVisibilityFixture) notify(t *testing.T, reason, record, subject, root string) { + t.Helper() + var recordURI, recordCID, actor, subjectURI, recordCreatedAt any = record, "bafyunreadrecord", f.actor, subject, f.sortAt + switch reason { + case "mention": + subjectURI = nil + case "upvote": + // Upvote groups carry no record, actor, or record time (schema CHECK). + recordURI, recordCID, actor, recordCreatedAt = nil, nil, nil, nil + } + _, err := f.db.ExecContext(context.Background(), `INSERT INTO notifications + (recipient_did, reason, record_uri, record_cid, actor_did, subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + f.recipient, reason, recordURI, recordCID, actor, subjectURI, root, recordCreatedAt, f.sortAt) + require.NoError(t, err) +} + +// Isolate one post reference at a time; the other references are indexed and +// accepted, so a hidden case can only disappear because of the selected post. +func (f *unreadVisibilityFixture) notifyPostReference(t *testing.T, position, postURI string) { + t.Helper() + switch position { + case "subject": + f.notify(t, "postReply", f.comment(t, f.root), postURI, f.root) + case "source": + f.notify(t, "mention", postURI, "", f.root) + case "root": + f.notify(t, "commentReply", f.comment(t, postURI), f.comment(t, postURI), postURI) + default: + t.Fatalf("unknown post reference position %q", position) + } +} + +func (f *unreadVisibilityFixture) requireCount(t *testing.T, expected int) { + t.Helper() + count, err := NewNotificationRepository(f.db).(notifications.ReadRepository).CountUnread(context.Background(), f.recipient) + require.NoError(t, err) + require.Equal(t, expected, count) +} + +func (f *unreadVisibilityFixture) deletePost(t *testing.T, uri string) { + t.Helper() + _, err := f.db.ExecContext(context.Background(), `UPDATE posts SET deleted_at = $2 WHERE uri = $1`, uri, f.sortAt) + require.NoError(t, err) +} + +func (f *unreadVisibilityFixture) deleteComment(t *testing.T, uri string) { + t.Helper() + _, err := f.db.ExecContext(context.Background(), `UPDATE comments SET deleted_at = $2 WHERE uri = $1`, uri, f.sortAt) + require.NoError(t, err) +} + +func TestNotificationUnreadVisibility_PostAdmission(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, position string + status posts.AdmissionStatus + legacy, drifted bool + want int + }{ + {"subject pending", "subject", posts.AdmissionStatusPending, false, false, 1}, + {"subject rejected", "subject", posts.AdmissionStatusRejected, false, false, 1}, + {"subject pending reacceptance", "subject", posts.AdmissionStatusPendingReacceptance, false, false, 1}, + {"subject no admission", "subject", "", false, false, 1}, + {"subject edited after acceptance", "subject", "", false, true, 1}, + {"subject accepted at current CID", "subject", posts.AdmissionStatusAccepted, false, false, 2}, + {"source pending", "source", posts.AdmissionStatusPending, false, false, 1}, + {"source accepted at current CID", "source", posts.AdmissionStatusAccepted, false, false, 2}, + {"root pending", "root", posts.AdmissionStatusPending, false, false, 1}, + {"root accepted at current CID", "root", posts.AdmissionStatusAccepted, false, false, 2}, + {"legacy root without admission", "root", "", true, false, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + uri := f.post(t, tc.status, tc.legacy) + if tc.drifted { + seedVisibilityAdmissionDriftedCID(t, f.db, f.community, uri) + } + f.notifyPostReference(t, tc.position, uri) + f.requireCount(t, tc.want) + }) + } +} + +func TestNotificationUnreadVisibility_ModeratorRemoval(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, position string + status posts.AdmissionStatus + marker, deleted, foreignRemoval, authorDelete, legacy bool + want int + }{ + {"subject removed with marker", "subject", posts.AdmissionStatusRemoved, true, false, false, false, false, 2}, + {"source removed with marker", "source", posts.AdmissionStatusRemoved, true, false, false, false, false, 2}, + {"source removed with marker and deleted", "source", posts.AdmissionStatusRemoved, true, true, false, false, false, 2}, + {"root removed with marker", "root", posts.AdmissionStatusRemoved, true, false, false, false, false, 2}, + {"subject removed with marker and deleted", "subject", posts.AdmissionStatusRemoved, true, true, false, false, false, 2}, + {"root removed with marker and deleted", "root", posts.AdmissionStatusRemoved, true, true, false, false, false, 2}, + {"subject removed without marker", "subject", posts.AdmissionStatusRemoved, false, false, false, false, false, 1}, + {"subject removal lifted with stale marker", "subject", posts.AdmissionStatusPending, true, false, false, false, false, 1}, + {"subject foreign removal cannot qualify", "subject", posts.AdmissionStatusPending, true, false, true, false, false, 1}, + {"subject removed with stale rev and both markers", "subject", posts.AdmissionStatusRemoved, true, true, false, true, false, 2}, + {"legacy root removed without marker", "root", posts.AdmissionStatusRemoved, false, false, false, false, true, 1}, + {"subject unaccepted then deleted with only community withdrawal marker", "subject", posts.AdmissionStatusPending, true, true, false, false, false, 1}, + {"subject removed undeleted with only author delete marker", "subject", posts.AdmissionStatusRemoved, false, false, false, true, false, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + uri := f.post(t, tc.status, tc.legacy) + if tc.legacy { + seedVisibilityAdmission(t, f.db, f.community, uri, tc.status, "", "") + } + if tc.foreignRemoval { + otherCommunity := visibilityCommunity(t, f.db, testkit.UniqueID(t)) + seedVisibilityAdmission(t, f.db, otherCommunity, uri, posts.AdmissionStatusRemoved, "", "") + } + if tc.marker { + // The differing rev in the both-markers case is deliberate: the + // placeholder depends on the marker's existence, not rev equality. + seedWithdrawalMarker(t, f.db, uri, "communityWithdrawal", "3lqqqqqqqqqq1") + } + if tc.authorDelete { + seedWithdrawalMarker(t, f.db, uri, "authorDelete", nil) + } + if tc.deleted { + f.deletePost(t, uri) + } + f.notifyPostReference(t, tc.position, uri) + f.requireCount(t, tc.want) + }) + } +} + +func TestNotificationUnreadVisibility_AuthorDeletion(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, position string + status posts.AdmissionStatus + marker, legacy bool + want int + }{ + {"subject accepted with author delete marker", "subject", posts.AdmissionStatusAccepted, true, false, 2}, + {"source pending with author delete marker", "source", posts.AdmissionStatusPending, true, false, 2}, + {"root accepted with author delete marker", "root", posts.AdmissionStatusAccepted, true, false, 2}, + {"subject pending with author delete marker", "subject", posts.AdmissionStatusPending, true, false, 2}, + {"source accepted with author delete marker", "source", posts.AdmissionStatusAccepted, true, false, 2}, + {"root pending with author delete marker", "root", posts.AdmissionStatusPending, true, false, 2}, + {"subject accepted without marker", "subject", posts.AdmissionStatusAccepted, false, false, 1}, + {"subject pending without marker", "subject", posts.AdmissionStatusPending, false, false, 1}, + {"subject rejected without marker", "subject", posts.AdmissionStatusRejected, false, false, 1}, + {"subject pending reacceptance without marker", "subject", posts.AdmissionStatusPendingReacceptance, false, false, 1}, + {"subject no admission without marker", "subject", "", false, false, 1}, + {"legacy root without marker", "root", "", false, true, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + uri := f.post(t, tc.status, tc.legacy) + f.deletePost(t, uri) + if tc.marker { + seedWithdrawalMarker(t, f.db, uri, "authorDelete", nil) + } + f.notifyPostReference(t, tc.position, uri) + f.requireCount(t, tc.want) + }) + } +} + +func TestNotificationUnreadVisibility_DeletedCommentsRemainVisible(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, reason, deletedReference string }{ + {"commentReply deleted record", "commentReply", "record"}, + {"commentReply deleted subject", "commentReply", "subject"}, + {"postReply deleted record", "postReply", "record"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + record := f.comment(t, f.root) + subject := f.root + if tc.reason == "commentReply" { + subject = f.comment(t, f.root) + } + if tc.deletedReference == "record" { + f.deleteComment(t, record) + } else { + f.deleteComment(t, subject) + } + f.notify(t, tc.reason, record, subject, f.root) + f.requireCount(t, 2) + }) + } +} + +func TestNotificationUnreadVisibility_UnindexedAndNonPostReferences(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, reason, missing string }{ + {"missing record comment", "commentReply", "recordComment"}, + {"missing post mention source", "mention", "recordPost"}, + {"missing subject comment", "commentReply", "subjectComment"}, + {"missing subject post", "postReply", "subjectPost"}, + {"missing root post", "commentReply", "rootPost"}, + {"root resolves to indexed comment", "commentReply", "rootComment"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + record := f.comment(t, f.root) + subject := f.comment(t, f.root) + root := f.root + switch tc.missing { + case "recordComment": + record = "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + case "recordPost": + record = postV2URI(f.recipient, testkit.TID()) + subject = "" + case "subjectComment": + subject = "at://" + f.recipient + "/social.coves.community.comment/" + testkit.TID() + case "subjectPost": + subject = postV2URI(f.recipient, testkit.TID()) + case "rootPost": + root = postV2URI(f.recipient, testkit.TID()) + case "rootComment": + root = f.comment(t, f.root) + } + f.notify(t, tc.reason, record, subject, root) + f.requireCount(t, 1) + }) + } +} + +// Upvote groups have no record; only the subject (and the accepted root) is +// referenced. The root stays the fixture's accepted post so each case isolates +// the subject's state. Every group has a qualifying vote so only the subject's +// visibility changes the expected count. +func TestNotificationUnreadVisibility_Upvote(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, subject string + want int + }{ + {"subject post accepted", "acceptedPost", 2}, + {"subject post pending", "pendingPost", 1}, + {"subject comment deleted", "deletedComment", 2}, + {"subject comment unindexed", "unindexedComment", 1}, + {"subject in unsupported collection", "unsupportedCollection", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + var subject string + switch tc.subject { + case "acceptedPost": + subject = f.post(t, posts.AdmissionStatusAccepted, false) + case "pendingPost": + subject = f.post(t, posts.AdmissionStatusPending, false) + case "deletedComment": + subject = seedActorComment(t, f.db, f.recipient, f.root, testkit.TID(), f.sortAt) + f.deleteComment(t, subject) + case "unindexedComment": + subject = "at://" + f.recipient + "/social.coves.community.comment/" + testkit.TID() + case "unsupportedCollection": + subject = "at://" + f.recipient + "/social.coves.feed.vote/" + testkit.TID() + } + f.insertVote(t, "did:plc:unreadvoter"+testkit.UniqueID(t), subject, false) + f.notify(t, "upvote", "", subject, f.root) + f.requireCount(t, tc.want) + }) + } +} + +func TestNotificationUnreadVisibility_CommentMention(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, source string + want int + }{ + {"comment source live", "live", 2}, + {"comment source deleted", "deleted", 2}, + {"comment source unindexed", "unindexed", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newUnreadVisibilityFixture(t) + record := f.comment(t, f.root) + switch tc.source { + case "deleted": + f.deleteComment(t, record) + case "unindexed": + record = "at://" + f.actor + "/social.coves.community.comment/" + testkit.TID() + } + f.notify(t, "mention", record, "", f.root) + f.requireCount(t, tc.want) + }) + } +} diff --git a/internal/db/postgres/notification_upvote_group_alive_guard_test.go b/internal/db/postgres/notification_upvote_group_alive_guard_test.go new file mode 100644 index 0000000..01bb964 --- /dev/null +++ b/internal/db/postgres/notification_upvote_group_alive_guard_test.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpvoteGroupSQL_RejectsArgumentsThatBindInsideFragments(t *testing.T) { + for _, bridgedTotals := range []bool{false, true} { + gate := "off" + if bridgedTotals { + gate = "on" + } + for _, tc := range []struct{ name, expression string }{ + {"bare column", "subject_uri"}, + {"string literal", "'x'"}, + {"placeholder expression", "$1 OR true"}, + {"statement expression", "n.subject_uri; DROP"}, + {"qualifying upvote alias", "qualifying_upvote_block.blocker_did"}, + {"bridged post probe alias", "bridged_post.uri"}, + {"bridged comment probe alias", "bridged_comment.uri"}, + } { + t.Run(gate+"/subject/"+tc.name, func(t *testing.T) { + require.Panics(t, func() { upvoteGroupAliveSQL(tc.expression, "$1", bridgedTotals) }) + }) + t.Run(gate+"/recipient/"+tc.name, func(t *testing.T) { + require.Panics(t, func() { upvoteGroupAliveSQL("$2", tc.expression, bridgedTotals) }) + }) + t.Run(gate+"/bridged-total/"+tc.name, func(t *testing.T) { + require.Panics(t, func() { bridgedUpvoteTotalSQL(tc.expression, bridgedTotals) }) + }) + } + } +} + +func TestUpvoteGroupSQL_AcceptsPlaceholdersAndOuterQualifiedColumns(t *testing.T) { + for _, bridgedTotals := range []bool{false, true} { + gate := "off" + if bridgedTotals { + gate = "on" + } + for _, tc := range []struct{ name, subject, recipient string }{ + {"placeholders", "$2", "$1"}, + {"notification row", "n.subject_uri", "n.recipient_did"}, + {"sweep row", "s.subject_uri", "n.recipient_did"}, + } { + t.Run(gate+"/"+tc.name, func(t *testing.T) { + require.NotPanics(t, func() { upvoteGroupAliveSQL(tc.subject, tc.recipient, bridgedTotals) }) + require.NotPanics(t, func() { bridgedUpvoteTotalSQL(tc.subject, bridgedTotals) }) + }) + } + } +} diff --git a/internal/db/postgres/notification_upvote_group_bridged_alive_test.go b/internal/db/postgres/notification_upvote_group_bridged_alive_test.go new file mode 100644 index 0000000..8478f85 --- /dev/null +++ b/internal/db/postgres/notification_upvote_group_bridged_alive_test.go @@ -0,0 +1,191 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +func bridgedGroupSubject(t *testing.T, fixture *unreadVisibilityFixture, kind string) string { + t.Helper() + if kind == "comment" { + return seedActorComment(t, fixture.db, fixture.recipient, fixture.root, testkit.TID(), fixture.sortAt) + } + return fixture.post(t, posts.AdmissionStatusAccepted, false) +} + +func setBridgedGroupTotals(t *testing.T, db *sql.DB, kind, subject string, upvotes, downvotes int) { + t.Helper() + table := "posts" + if kind == "comment" { + table = "comments" + } + result, err := db.Exec(`UPDATE `+table+` SET bridged_upvote_count = $2, + bridged_downvote_count = $3, bridged_stats_as_of = NOW() WHERE uri = $1`, subject, upvotes, downvotes) + require.NoError(t, err) + rows, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, rows, "the subject must exist before maintenance") +} + +func bridgedGroupDisqualifiedVotes(t *testing.T, db *sql.DB, recipient, subject string, at time.Time) { + t.Helper() + votes := qualifyingUpvoteFixture{db: db} + voter := func() string { return "did:plc:" + testkit.UniqueID(t) } + votes.insertVote(t, recipient, subject, "up", at, false) + erased := voter() + _, err := db.Exec(`INSERT INTO deleted_accounts (did) VALUES ($1)`, erased) + require.NoError(t, err) + votes.insertVote(t, erased, subject, "up", at, false) + aggregator := voter() + _, err = db.Exec(`INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, 'Aggregator voter', $2, 'bafybridgedaggregator')`, aggregator, + "at://"+aggregator+"/social.coves.aggregator.service/self") + require.NoError(t, err) + votes.insertVote(t, aggregator, subject, "up", at, false) + blocked := voter() + votes.insertBlock(t, recipient, blocked) + votes.insertVote(t, blocked, subject, "up", at, false) + blocking := voter() + votes.insertBlock(t, blocking, recipient) + votes.insertVote(t, blocking, subject, "up", at, false) + votes.insertVote(t, voter(), subject, "up", at, true) + votes.insertVote(t, voter(), subject, "down", at, false) + require.False(t, votes.qualifies(t, subject, recipient), "none of the seven votes qualifies") +} + +func bridgedRetentionSweeper(t *testing.T, db *sql.DB) notifications.RetentionSweeper { + t.Helper() + sweeper, ok := NewNotificationRepository(db, WithBridgedUpvoteTotals()).(notifications.RetentionSweeper) + require.True(t, ok) + return sweeper +} + +func TestNotificationRepository_BridgedGroupMaintenanceAlive(t *testing.T) { + t.Parallel() + for _, kind := range []string{"post", "comment"} { + for _, operation := range []string{"delete_if_empty", "sweep"} { + for _, state := range []string{"bridged_upvotes", "downvotes_and_disqualified_votes"} { + t.Run(kind+"/"+operation+"/"+state, func(t *testing.T) { + t.Parallel() + fixture := newUnreadVisibilityFixture(t) + subject := bridgedGroupSubject(t, fixture, kind) + at := time.Date(2026, time.September, 30, 12, 0, 7, 123456000, time.UTC).Truncate(time.Microsecond) + group := insertDeleteTestGroup(t, upvoteGroupFixture{ctx: context.Background(), db: fixture.db, rootPostURI: fixture.root}, fixture.recipient, subject, at) + if state == "bridged_upvotes" { + setBridgedGroupTotals(t, fixture.db, kind, subject, 3, 0) + } else { + setBridgedGroupTotals(t, fixture.db, kind, subject, 0, 4) + bridgedGroupDisqualifiedVotes(t, fixture.db, fixture.recipient, subject, at) + } + repository := NewNotificationRepository(fixture.db, WithBridgedUpvoteTotals()) + if operation == "delete_if_empty" { + transaction, err := fixture.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, repository.ApplyUpvoteGroupTx(context.Background(), transaction, deleteIfEmptyIntent(fixture.recipient, subject))) + require.NoError(t, transaction.Commit()) + } else { + deleted, err := bridgedRetentionSweeper(t, fixture.db).SweepEmptyUpvoteGroups(context.Background()) + require.NoError(t, err) + if state == "bridged_upvotes" { + require.EqualValues(t, 0, deleted) + } else { + require.EqualValues(t, 1, deleted) + } + } + var count int + var storedSort sql.NullTime + require.NoError(t, fixture.db.QueryRow(`SELECT count(*), min(sort_at) FROM notifications WHERE id = $1`, group).Scan(&count, &storedSort)) + if state == "bridged_upvotes" { + require.Equal(t, 1, count, "bridged upvotes alone keep the group") + require.True(t, storedSort.Valid && storedSort.Time.Equal(at), "maintenance must preserve sort_at") + } else { + require.Equal(t, 0, count, "downvotes and disqualified votes cannot keep the group") + } + }) + } + } + } +} + +func TestNotificationRetention_BridgedTotalRecheckedAfterCandidateLocks(t *testing.T) { + t.Parallel() + fixture := newUnreadVisibilityFixture(t) + label := testkit.TID() + subject := seedVisibilityPost(t, fixture.db, fixture.community, fixture.recipient, label, "recheck", fixture.sortAt) + seedVisibilityAdmission(t, fixture.db, fixture.community, subject, posts.AdmissionStatusAccepted, "", "") + at := time.Date(2026, time.September, 30, 12, 0, 7, 123456000, time.UTC).Truncate(time.Microsecond) + ids := make(map[string]int64) + retentionRow(t, fixture.db, ids, label, fixture.recipient, "upvote", at) + retentionRow(t, fixture.db, ids, "still_empty", fixture.recipient, "upvote", at) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + holder, holderPID := notificationRaceTransaction(t, fixture.db, ctx) + _, err := holder.ExecContext(ctx, `LOCK TABLE notifications IN SHARE MODE`) + require.NoError(t, err) + type result struct { + deleted int64 + err error + } + results := make(chan result, 1) + sweeper := bridgedRetentionSweeper(t, fixture.db) + go func() { + deleted, err := sweeper.SweepEmptyUpvoteGroups(ctx) + results <- result{deleted, err} + }() + testkit.WaitFor(t, 3*time.Second, func() (bool, error) { + select { + case got := <-results: + return false, fmt.Errorf("sweep returned (%d, %v) before its DELETE waited", got.deleted, got.err) + default: + } + var waiterPID int + err := fixture.db.QueryRowContext(ctx, `SELECT pid FROM pg_locks + WHERE relation = 'notifications'::regclass AND locktype = 'relation' + AND mode = 'RowExclusiveLock' AND NOT granted + AND $1 = ANY(pg_blocking_pids(pid)) LIMIT 1`, holderPID).Scan(&waiterPID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err + }, testkit.WithDescription("bridged sweep DELETE waiting after locking candidates")) + _, err = holder.ExecContext(ctx, `SAVEPOINT bridged_probe`) + require.NoError(t, err) + var lockedID int64 + err = holder.QueryRowContext(ctx, `SELECT id FROM notifications WHERE id = $1 FOR UPDATE NOWAIT`, ids[label]).Scan(&lockedID) + var lockError *pq.Error + require.ErrorAs(t, err, &lockError, "the zero-total candidate must already be locked") + require.Equal(t, pq.ErrorCode("55P03"), lockError.Code) + _, err = holder.ExecContext(ctx, `ROLLBACK TO SAVEPOINT bridged_probe`) + require.NoError(t, err) + _, err = holder.ExecContext(ctx, `UPDATE posts SET bridged_upvote_count = 2, bridged_stats_as_of = NOW() WHERE uri = $1`, subject) + require.NoError(t, err) + require.NoError(t, holder.Commit()) + select { + case got := <-results: + require.NoError(t, got.err) + require.EqualValues(t, 1, got.deleted) + case <-ctx.Done(): + t.Fatalf("sweep did not finish: %v", ctx.Err()) + } + var storedSort time.Time + require.NoError(t, fixture.db.QueryRow(`SELECT sort_at FROM notifications WHERE id = $1`, ids[label]).Scan(&storedSort), "newly bridged group must survive") + require.True(t, storedSort.Equal(at)) + var emptyCount int + require.NoError(t, fixture.db.QueryRow(`SELECT count(*) FROM notifications WHERE id = $1`, ids["still_empty"]).Scan(&emptyCount)) + require.Equal(t, 0, emptyCount) +} diff --git a/internal/db/postgres/notification_upvote_group_delete_test.go b/internal/db/postgres/notification_upvote_group_delete_test.go new file mode 100644 index 0000000..e535b2e --- /dev/null +++ b/internal/db/postgres/notification_upvote_group_delete_test.go @@ -0,0 +1,188 @@ +//go:build integration + +package postgres + +import ( + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func insertDeleteTestGroup(t *testing.T, fixture upvoteGroupFixture, recipient, subject string, sortAt time.Time) int64 { + t.Helper() + var id int64 + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $3, $4) RETURNING id`, + recipient, subject, fixture.rootPostURI, sortAt).Scan(&id)) + return id +} + +func deleteTestGroupCount(t *testing.T, fixture upvoteGroupFixture, recipient, subject string) int { + t.Helper() + var count int + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + SELECT count(*) FROM notifications + WHERE recipient_did = $1 AND subject_uri = $2 AND reason = 'upvote'`, + recipient, subject).Scan(&count)) + return count +} + +func deleteIfEmptyIntent(recipient, subject string) notifications.UpvoteGroupIntent { + return notifications.UpvoteGroupIntent{ + Action: notifications.UpvoteGroupDeleteIfEmpty, + RecipientDID: recipient, SubjectURI: subject, + } +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_DeleteIfEmpty_QualifyingVotePreservesGroupAndSort(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + pastTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC) + groupID := insertDeleteTestGroup(t, fixture, fixture.recipientDID, fixture.subjectURI, pastTime) + emptySubject := fixture.subjectURI + "-empty" + insertDeleteTestGroup(t, fixture, fixture.recipientDID, emptySubject, pastTime) + votes := qualifyingUpvoteFixture{db: fixture.db} + votes.insertVote(t, "did:plc:"+testkit.UniqueID(t)+"voter", fixture.subjectURI, "up", time.Now().UTC(), false) + + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, + deleteIfEmptyIntent(fixture.recipientDID, emptySubject)), + "positive control: an empty group in this fixture must be deletable") + var emptyCount int + require.NoError(t, transaction.QueryRowContext(fixture.ctx, `SELECT count(*) FROM notifications WHERE id <> $1 AND reason = 'upvote'`, groupID).Scan(&emptyCount)) + require.Zero(t, emptyCount, "positive control: the empty group must have been deleted") + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, + deleteIfEmptyIntent(fixture.recipientDID, fixture.subjectURI))) + var storedID int64 + var sortAt time.Time + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT id, sort_at FROM notifications + WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&storedID, &sortAt), + "a live qualifying vote must preserve its group") + require.Equal(t, groupID, storedID, "the qualifying vote must preserve the same row") + require.Truef(t, sortAt.Equal(pastTime), "delete-if-empty changed sort_at from %s to %s", pastTime, sortAt) +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_DeleteIfEmpty_OnlyDisqualifiedVotesRemain(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + setup func(*testing.T, upvoteGroupFixture, qualifyingUpvoteFixture, string, time.Time) + }{ + {"recipient_self_upvote", func(t *testing.T, fixture upvoteGroupFixture, votes qualifyingUpvoteFixture, _ string, now time.Time) { + votes.insertVote(t, fixture.recipientDID, fixture.subjectURI, "up", now, false) + }}, + {"erased_voter", func(t *testing.T, fixture upvoteGroupFixture, votes qualifyingUpvoteFixture, voter string, now time.Time) { + _, err := fixture.db.ExecContext(fixture.ctx, `INSERT INTO deleted_accounts (did) VALUES ($1)`, voter) + require.NoError(t, err) + votes.insertVote(t, voter, fixture.subjectURI, "up", now, false) + }}, + {"aggregator_voter", func(t *testing.T, fixture upvoteGroupFixture, votes qualifyingUpvoteFixture, voter string, now time.Time) { + _, err := fixture.db.ExecContext(fixture.ctx, `INSERT INTO aggregators (did, display_name, record_uri, record_cid) + VALUES ($1, 'Aggregator voter', $2, 'bafydeleteaggregator')`, voter, + "at://"+voter+"/social.coves.aggregator.service/self") + require.NoError(t, err) + votes.insertVote(t, voter, fixture.subjectURI, "up", now, false) + }}, + {"recipient_blocks_voter", func(t *testing.T, fixture upvoteGroupFixture, votes qualifyingUpvoteFixture, voter string, now time.Time) { + votes.insertBlock(t, fixture.recipientDID, voter) + votes.insertVote(t, voter, fixture.subjectURI, "up", now, false) + }}, + {"voter_blocks_recipient", func(t *testing.T, fixture upvoteGroupFixture, votes qualifyingUpvoteFixture, voter string, now time.Time) { + votes.insertBlock(t, voter, fixture.recipientDID) + votes.insertVote(t, voter, fixture.subjectURI, "up", now, false) + }}, + {"downvotes", func(t *testing.T, fixture upvoteGroupFixture, votes qualifyingUpvoteFixture, voter string, now time.Time) { + votes.insertVote(t, voter, fixture.subjectURI, "down", now, false) + }}, + {"soft_deleted_upvotes", func(t *testing.T, fixture upvoteGroupFixture, votes qualifyingUpvoteFixture, voter string, now time.Time) { + votes.insertVote(t, voter, fixture.subjectURI, "up", now, true) + }}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + insertDeleteTestGroup(t, fixture, fixture.recipientDID, fixture.subjectURI, time.Now().UTC()) + require.Equal(t, 1, deleteTestGroupCount(t, fixture, fixture.recipientDID, fixture.subjectURI), + "the group must exist before attempting deletion") + votes := qualifyingUpvoteFixture{db: fixture.db} + test.setup(t, fixture, votes, "did:plc:"+testkit.UniqueID(t)+"voter", time.Now().UTC()) + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, + deleteIfEmptyIntent(fixture.recipientDID, fixture.subjectURI))) + require.NoError(t, transaction.Commit()) + require.Zero(t, deleteTestGroupCount(t, fixture, fixture.recipientDID, fixture.subjectURI), + "no live qualifying upvote remains on this subject") + }) + } +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_DeleteIfEmpty_OnlyDeletesMatchingUpvoteGroup(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + pastTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC) + targetID := insertDeleteTestGroup(t, fixture, fixture.recipientDID, fixture.subjectURI, pastTime) + otherRecipientID := insertDeleteTestGroup(t, fixture, fixture.otherRecipientDID, fixture.subjectURI, pastTime) + otherSubject := fixture.subjectURI + "-other" + otherSubjectID := insertDeleteTestGroup(t, fixture, fixture.recipientDID, otherSubject, pastTime) + recordURI := "at://" + fixture.otherRecipientDID + "/social.coves.community.comment/reply" + var replyID int64 + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + INSERT INTO notifications (recipient_did, reason, record_uri, record_cid, actor_did, + subject_uri, root_post_uri, record_created_at, sort_at) + VALUES ($1, 'postReply', $2, 'bafydeletegroupreply', $3, $4, $5, $6, $7) + RETURNING id`, fixture.recipientDID, recordURI, fixture.otherRecipientDID, + fixture.subjectURI, fixture.rootPostURI, time.Now().UTC(), pastTime).Scan(&replyID)) + require.Equal(t, 1, deleteTestGroupCount(t, fixture, fixture.recipientDID, fixture.subjectURI), + "the target group must exist before attempting deletion") + + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, + deleteIfEmptyIntent(fixture.recipientDID, fixture.subjectURI))) + require.NoError(t, transaction.Commit()) + var count int + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, `SELECT count(*) FROM notifications WHERE id = $1`, targetID).Scan(&count)) + require.Zero(t, count, "positive control: the intended empty group must be deleted") + for _, untouchedID := range []int64{otherRecipientID, otherSubjectID, replyID} { + var storedID int64 + var sortAt time.Time + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, `SELECT id, sort_at FROM notifications WHERE id = $1`, + untouchedID).Scan(&storedID, &sortAt), "deletion must be scoped to the intended recipient, subject and reason") + require.Equal(t, untouchedID, storedID) + require.True(t, sortAt.Equal(pastTime), "an unrelated notification's sort_at must not change") + } +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_DeleteIfEmpty_RollbackRestoresGroup(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + groupID := insertDeleteTestGroup(t, fixture, fixture.recipientDID, fixture.subjectURI, time.Now().UTC()) + require.Equal(t, 1, deleteTestGroupCount(t, fixture, fixture.recipientDID, fixture.subjectURI), + "the group must exist before attempting deletion") + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, + deleteIfEmptyIntent(fixture.recipientDID, fixture.subjectURI))) + var count int + require.NoError(t, transaction.QueryRowContext(fixture.ctx, `SELECT count(*) FROM notifications WHERE id = $1`, groupID).Scan(&count)) + require.Zero(t, count, "the caller's transaction must see the deletion") + require.NoError(t, transaction.Rollback()) + var restoredID int64 + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + SELECT id FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&restoredID)) + require.Equal(t, groupID, restoredID, "rolling back the caller's transaction must restore the same group") +} diff --git a/internal/db/postgres/notification_upvote_group_test.go b/internal/db/postgres/notification_upvote_group_test.go new file mode 100644 index 0000000..d8f5870 --- /dev/null +++ b/internal/db/postgres/notification_upvote_group_test.go @@ -0,0 +1,343 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "Coves/internal/core/notifications" + "Coves/tests/testkit" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +type upvoteGroupFixture struct { + ctx context.Context + db *sql.DB + repository notifications.Repository + recipientDID string + otherRecipientDID string + subjectURI string + rootPostURI string +} + +func newUpvoteGroupFixture(t *testing.T) upvoteGroupFixture { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + db := testkit.DB(t) + uniqueID := testkit.UniqueID(t) + recipientDID := "did:plc:" + uniqueID + "recipient" + otherRecipientDID := "did:plc:" + uniqueID + "other" + createTestUser(t, db, uniqueID+"recipient.test", recipientDID) + createTestUser(t, db, uniqueID+"other.test", otherRecipientDID) + return upvoteGroupFixture{ + ctx: ctx, db: db, repository: NewNotificationRepository(db), + recipientDID: recipientDID, otherRecipientDID: otherRecipientDID, + subjectURI: "at://" + recipientDID + "/social.coves.community.comment/subject", + rootPostURI: "at://" + recipientDID + "/social.coves.community.postv2/root", + } +} + +func (fixture upvoteGroupFixture) bump() notifications.UpvoteGroupIntent { + return notifications.UpvoteGroupIntent{ + Action: notifications.UpvoteGroupBump, RecipientDID: fixture.recipientDID, + SubjectURI: fixture.subjectURI, RootPostURI: fixture.rootPostURI, + } +} + +// clockTimestamp reads clock_timestamp() through queryer. Reads taken around a +// notification write bound its sort_at, which is the write statement's +// clock_timestamp() rather than the transaction's now(). +func clockTimestamp(t *testing.T, ctx context.Context, queryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +}, +) time.Time { + t.Helper() + var clock time.Time + require.NoError(t, queryer.QueryRowContext(ctx, `SELECT clock_timestamp()`).Scan(&clock)) + return clock +} + +func requireSortAtWithin(t *testing.T, sortAt, notBefore, notAfter time.Time) { + t.Helper() + require.Falsef(t, sortAt.Before(notBefore) || sortAt.After(notAfter), + "sort_at = %s, want the write's clock_timestamp() in [%s, %s]", sortAt, notBefore, notAfter) +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_CreatesGroupInCallerTransaction(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + transaction, err := fixture.db.BeginTx(fixture.ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + var transactionTime, clockBeforeWrite time.Time + require.NoError(t, transaction.QueryRowContext(fixture.ctx, `SELECT now(), clock_timestamp()`). + Scan(&transactionTime, &clockBeforeWrite)) + require.Truef(t, clockBeforeWrite.After(transactionTime), + "fixture: clock read %s must follow transaction start %s", clockBeforeWrite, transactionTime) + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, fixture.bump())) + clockAfterWrite := clockTimestamp(t, fixture.ctx, transaction) + + var count int + require.NoError(t, transaction.QueryRowContext(fixture.ctx, `SELECT count(*) FROM notifications`).Scan(&count)) + require.Equal(t, 1, count, "the caller transaction must see one upvote group") + var recipientDID, reason, subjectURI, rootPostURI string + var recordURI, recordCID, actorDID sql.NullString + var recordCreatedAt sql.NullTime + var sortAt time.Time + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT recipient_did, reason, subject_uri, root_post_uri, + record_uri, record_cid, actor_did, record_created_at, sort_at + FROM notifications`).Scan(&recipientDID, &reason, &subjectURI, &rootPostURI, + &recordURI, &recordCID, &actorDID, &recordCreatedAt, &sortAt)) + require.Equal(t, fixture.recipientDID, recipientDID) + require.Equal(t, string(notifications.ReasonUpvote), reason) + require.Equal(t, fixture.subjectURI, subjectURI) + require.Equal(t, fixture.rootPostURI, rootPostURI) + require.False(t, recordURI.Valid, "a group has no record URI") + require.False(t, recordCID.Valid, "a group has no record CID") + require.False(t, actorDID.Valid, "a group has no actor") + require.False(t, recordCreatedAt.Valid, "a group has no record creation time") + requireSortAtWithin(t, sortAt, clockBeforeWrite, clockAfterWrite) +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_RaisesSortWithoutReplacingGroup(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + firstTransaction, err := fixture.db.BeginTx(fixture.ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer firstTransaction.Rollback() + var firstTime time.Time + require.NoError(t, firstTransaction.QueryRowContext(fixture.ctx, `SELECT now()`).Scan(&firstTime)) + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, firstTransaction, fixture.bump())) + require.NoError(t, firstTransaction.Commit()) + var firstID int64 + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + SELECT id FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&firstID), "first bump must create the group") + + secondTransaction, err := fixture.db.BeginTx(fixture.ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer secondTransaction.Rollback() + var secondTime, clockBeforeWrite time.Time + require.NoError(t, secondTransaction.QueryRowContext(fixture.ctx, `SELECT now(), clock_timestamp()`). + Scan(&secondTime, &clockBeforeWrite)) + require.True(t, secondTime.After(firstTime), "the second transaction must begin later: first %s, second %s", firstTime, secondTime) + require.Truef(t, clockBeforeWrite.After(secondTime), + "fixture: clock read %s must follow transaction start %s", clockBeforeWrite, secondTime) + intent := fixture.bump() + intent.RootPostURI = "at://" + fixture.otherRecipientDID + "/social.coves.community.postv2/different" + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, secondTransaction, intent)) + clockAfterWrite := clockTimestamp(t, fixture.ctx, secondTransaction) + require.NoError(t, secondTransaction.Commit()) + + var count int + var storedID int64 + var storedRoot string + var sortAt time.Time + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + SELECT count(*), min(id), min(root_post_uri), min(sort_at) + FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&count, &storedID, &storedRoot, &sortAt)) + require.Equal(t, 1, count, "a later bump must not insert a second group") + require.Equal(t, firstID, storedID, "a later bump must retain the group's id") + require.Equal(t, fixture.rootPostURI, storedRoot, "the first root_post_uri must survive a conflicting bump") + requireSortAtWithin(t, sortAt, clockBeforeWrite, clockAfterWrite) +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_EarlierTransactionCannotMoveSortBackwards(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + earlyTransaction, err := fixture.db.BeginTx(fixture.ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer earlyTransaction.Rollback() + var earlyTime time.Time + require.NoError(t, earlyTransaction.QueryRowContext(fixture.ctx, `SELECT now()`).Scan(&earlyTime)) + + lateTransaction, err := fixture.db.BeginTx(fixture.ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer lateTransaction.Rollback() + var lateTime, lateClockBeforeWrite time.Time + require.NoError(t, lateTransaction.QueryRowContext(fixture.ctx, `SELECT now(), clock_timestamp()`). + Scan(&lateTime, &lateClockBeforeWrite)) + require.True(t, earlyTime.Before(lateTime), "early now() %s must precede late now() %s", earlyTime, lateTime) + require.Truef(t, lateClockBeforeWrite.After(lateTime), + "fixture: clock read %s must follow transaction start %s", lateClockBeforeWrite, lateTime) + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, lateTransaction, fixture.bump())) + lateClockAfterWrite := clockTimestamp(t, fixture.ctx, lateTransaction) + require.NoError(t, lateTransaction.Commit()) + var lateGroupID int64 + var lateSortAt time.Time + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + SELECT id, sort_at FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&lateGroupID, &lateSortAt), "late bump must create the group") + requireSortAtWithin(t, lateSortAt, lateClockBeforeWrite, lateClockAfterWrite) + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, earlyTransaction, fixture.bump())) + require.NoError(t, earlyTransaction.Commit()) + + var count int + var storedID int64 + var sortAt time.Time + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + SELECT count(*), min(id), min(sort_at) FROM notifications + WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&count, &storedID, &sortAt)) + require.Equal(t, 1, count) + require.Equal(t, lateGroupID, storedID) + require.Falsef(t, sortAt.Before(lateSortAt), + "early transaction must not lower sort_at: got %s, want at least the late write's %s", sortAt, lateSortAt) +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_ZeroActionDoesNotCreateGroup(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + intent := fixture.bump() + intent.Action = notifications.UpvoteGroupNoChange + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, intent)) + var count int + require.NoError(t, transaction.QueryRowContext(fixture.ctx, `SELECT count(*) FROM notifications`).Scan(&count)) + require.Zero(t, count, "the zero action must not create a group") +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_ZeroActionPreservesExistingSort(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + pastTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC) + _, err := fixture.db.ExecContext(fixture.ctx, ` + INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $3, $4)`, + fixture.recipientDID, fixture.subjectURI, fixture.rootPostURI, pastTime) + require.NoError(t, err) + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + intent := fixture.bump() + intent.Action = notifications.UpvoteGroupNoChange + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, intent)) + var count int + var sortAt time.Time + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT count(*), min(sort_at) FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&count, &sortAt)) + require.Equal(t, 1, count) + require.Truef(t, sortAt.Equal(pastTime), "zero action changed sort_at from %s to %s", pastTime, sortAt) +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_DifferentRecipientGetsSeparateGroup(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + _, err := fixture.db.ExecContext(fixture.ctx, ` + INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri) + VALUES ($1, 'upvote', $2, $3)`, fixture.recipientDID, fixture.subjectURI, fixture.rootPostURI) + require.NoError(t, err) + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + intent := fixture.bump() + intent.RecipientDID = fixture.otherRecipientDID + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, intent)) + var count int + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT count(*) FROM notifications WHERE reason = 'upvote' AND subject_uri = $1`, fixture.subjectURI).Scan(&count)) + require.Equal(t, 2, count, "a different recipient needs a separate group for the same subject") + var otherRoot string + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT root_post_uri FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.otherRecipientDID, fixture.subjectURI).Scan(&otherRoot)) + require.Equal(t, fixture.rootPostURI, otherRoot) +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_DoesNotChangeRecordKeyedNotification(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + pastTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC) + _, err := fixture.db.ExecContext(fixture.ctx, ` + INSERT INTO notifications (recipient_did, reason, subject_uri, root_post_uri, sort_at) + VALUES ($1, 'upvote', $2, $3, $4)`, fixture.recipientDID, fixture.subjectURI, fixture.rootPostURI, pastTime) + require.NoError(t, err) + recordURI := "at://" + fixture.otherRecipientDID + "/social.coves.community.comment/reply" + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + require.NoError(t, fixture.repository.ApplyTx(fixture.ctx, transaction, []notifications.Intent{{ + Reason: notifications.ReasonPostReply, RecipientDID: fixture.recipientDID, + ActorDID: fixture.otherRecipientDID, RecordURI: recordURI, RecordCID: "bafyrenotificationupvotereply", + SubjectURI: fixture.subjectURI, RootPostURI: fixture.rootPostURI, + RecordCreatedAt: time.Date(2026, time.September, 28, 12, 0, 0, 0, time.UTC), + }})) + var replyID int64 + var replySortAt time.Time + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT id, sort_at FROM notifications WHERE reason = 'postReply' AND record_uri = $1`, recordURI).Scan(&replyID, &replySortAt)) + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, fixture.bump())) + var recordCount, groupCount int + var storedReplyID int64 + var storedReplySortAt time.Time + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT count(*), min(id), min(sort_at) FROM notifications WHERE reason = 'postReply' AND record_uri = $1`, + recordURI).Scan(&recordCount, &storedReplyID, &storedReplySortAt)) + require.Equal(t, 1, recordCount, "the record-keyed reply must remain distinct from the upvote group") + require.Equal(t, replyID, storedReplyID) + require.True(t, storedReplySortAt.Equal(replySortAt), "a group bump must not update the reply's sort_at") + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT count(*) FROM notifications WHERE reason = 'upvote' AND recipient_did = $1 AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&groupCount)) + require.Equal(t, 1, groupCount, "a record-keyed reply must not count as an upvote group") +} + +// A recipient erased after the fan-out read its users row makes the upsert +// fail with 23503 on notifications_recipient_did_fkey. The write is skipped, +// not returned, so the vote transaction commits instead of dead-lettering. +func TestNotificationRepository_ApplyUpvoteGroupTx_MissingRecipientIsSkippedAndKeepsCallerWrites(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + transaction, err := fixture.db.BeginTx(fixture.ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + require.NoError(t, err) + defer transaction.Rollback() + // The valid recipient's group stands for the caller's earlier writes in + // the vote transaction, which skipping the missing recipient must not discard. + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, fixture.bump())) + intent := fixture.bump() + intent.RecipientDID += "missing" + require.NoError(t, fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, intent), + "only the missing recipient's foreign-key violation may be skipped") + + var answer int + require.NoError(t, transaction.QueryRowContext(fixture.ctx, `SELECT 1`).Scan(&answer), + "the transaction must remain usable after the skipped foreign-key violation") + require.Equal(t, 1, answer) + var missingCount int + require.NoError(t, transaction.QueryRowContext(fixture.ctx, ` + SELECT count(*) FROM notifications WHERE recipient_did = $1`, intent.RecipientDID).Scan(&missingCount)) + require.Zero(t, missingCount, "the missing recipient must get no upvote group") + require.NoError(t, transaction.Commit()) + var committedCount int + require.NoError(t, fixture.db.QueryRowContext(fixture.ctx, ` + SELECT count(*) FROM notifications WHERE recipient_did = $1 AND reason = 'upvote' AND subject_uri = $2`, + fixture.recipientDID, fixture.subjectURI).Scan(&committedCount)) + require.Equal(t, 1, committedCount, "the caller's earlier group write must commit") +} + +func TestNotificationRepository_ApplyUpvoteGroupTx_PropagatesOtherDatabaseErrors(t *testing.T) { + t.Parallel() + fixture := newUpvoteGroupFixture(t) + transaction, err := fixture.db.BeginTx(fixture.ctx, nil) + require.NoError(t, err) + defer transaction.Rollback() + intent := fixture.bump() + intent.SubjectURI += "\x00" + err = fixture.repository.ApplyUpvoteGroupTx(fixture.ctx, transaction, intent) + require.Error(t, err, "an error other than the recipient foreign-key violation must be returned") + var postgresError *pq.Error + require.True(t, errors.As(err, &postgresError), "the error must wrap the Postgres error") + require.Equal(t, pq.ErrorCode("22021"), postgresError.Code, "a NUL byte in text is an invalid byte sequence") +} diff --git a/internal/db/postgres/notification_visibility.go b/internal/db/postgres/notification_visibility.go new file mode 100644 index 0000000..05b6535 --- /dev/null +++ b/internal/db/postgres/notification_visibility.go @@ -0,0 +1,151 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + + "Coves/internal/core/posts" +) + +type notificationVisibilitySQL struct { + joins string + subjectPostState string + recordPostState string + rootPostState string + recordCommentState string + subjectCommentState string + // referencesVisible holds iff no required reference position reads hidden, + // including an unsupported collection; it ignores blocks, preferences and + // the upvote alive rule. visible is this plus those rules. + referencesVisible string + visible string +} + +// notificationVisibility builds the ONE read-time notification predicate and +// exposes the reference states for later list and response queries. An active +// admin moderation removal (moderation_decisions kind 'removal'; label decisions +// never count) reads as removedByServerAdmin when any active removal is +// instance-scope and removedByModerator when all are community-scope. +// +// A post whose own admission is removed with a public withdrawal marker (even if +// deleted) is removedByServerAdmin under an active instance removal, else +// removedByModerator. Otherwise an undeleted, publicly admitted post is live, or +// carries its removal status under an active removal; a deleted post with an +// author-delete marker is deleted (the author's delete wins); anything else is +// hidden, so a post that was never public stays hidden even when removed. +// Comments are hidden when unindexed, deleted when deleted, else live or the +// removal status of the active removals on the comment or its root post (a +// comment under an admin-removed post reads as removed). An absent reference position has NULL state; a row is visible +// iff no required reference is hidden. Later chunks extend this same predicate +// rather than copying it. +func notificationVisibility(bridgedTotals bool) notificationVisibilitySQL { + postCollection := fmt.Sprintf("split_part(%%s, '/', 4) IN ('%s', '%s')", posts.PostV2Collection, posts.LegacyPostCollection) + postLookup := func(alias, uri string) string { + admissionJoin, admitted := admittedPostsPredicate(anonymousViewerSQL) + return fmt.Sprintf(` + LEFT JOIN LATERAL ( + SELECT CASE + WHEN a.status = 'removed' AND EXISTS ( + SELECT 1 FROM notification_public_post_withdrawals withdrawal + WHERE withdrawal.post_uri = p.uri AND withdrawal.kind = 'communityWithdrawal') + THEN CASE WHEN EXISTS ( + SELECT 1 FROM moderation_decisions d + WHERE d.subject_uri = p.uri AND d.kind = 'removal' AND d.active AND d.scope_kind = 'instance') + THEN 'removedByServerAdmin' ELSE 'removedByModerator' END + WHEN p.deleted_at IS NULL AND %s THEN %s + WHEN p.deleted_at IS NOT NULL AND EXISTS ( + SELECT 1 FROM notification_public_post_withdrawals withdrawal + WHERE withdrawal.post_uri = p.uri AND withdrawal.kind = 'authorDelete') + THEN 'deleted' + ELSE 'hidden' + END AS state, p.cid + FROM posts p %s + WHERE p.uri = %s AND %s + ) %s ON true`, admitted, moderatedState("p.uri"), admissionJoin, uri, fmt.Sprintf(postCollection, uri), alias) + } + postState := func(alias string) string { return "COALESCE(" + alias + ".state, 'hidden')" } + commentLookup := func(alias, uri string) string { + return fmt.Sprintf(` + LEFT JOIN LATERAL ( + SELECT CASE WHEN c.deleted_at IS NOT NULL THEN 'deleted' ELSE %s END AS state, c.cid + FROM comments c WHERE c.uri = %s + ) %s ON true`, moderatedState("c.uri, c.root_uri"), uri, alias) + } + commentState := func(alias string) string { return "COALESCE(" + alias + ".state, 'hidden')" } + subjectCollection := "split_part(n.subject_uri, '/', 4)" + recordCollection := "split_part(n.record_uri, '/', 4)" + visibility := notificationVisibilitySQL{ + joins: postLookup("subject_post", "n.subject_uri") + + postLookup("record_post", "n.record_uri") + + postLookup("root_post", "n.root_post_uri") + + commentLookup("record_comment", "n.record_uri") + + commentLookup("subject_comment", "n.subject_uri"), + subjectPostState: fmt.Sprintf("CASE WHEN n.reason = 'postReply' OR (n.reason = 'upvote' AND "+postCollection+") THEN %s END", "n.subject_uri", postState("subject_post")), + recordPostState: fmt.Sprintf("CASE WHEN n.reason = 'mention' AND "+postCollection+" THEN %s END", "n.record_uri", postState("record_post")), + rootPostState: postState("root_post"), + recordCommentState: fmt.Sprintf("CASE WHEN n.reason IN ('postReply', 'commentReply') OR (n.reason = 'mention' AND %s = 'social.coves.community.comment') THEN %s END", recordCollection, commentState("record_comment")), + subjectCommentState: fmt.Sprintf("CASE WHEN n.reason = 'commentReply' OR (n.reason = 'upvote' AND %s = 'social.coves.community.comment') THEN %s END", subjectCollection, commentState("subject_comment")), + } + // A non-null position with an unsupported collection is also hidden. + states := []string{visibility.subjectPostState, visibility.recordPostState, visibility.rootPostState, + visibility.recordCommentState, visibility.subjectCommentState} + references := make([]string, 0, len(states)+2) + for _, state := range states { + references = append(references, "COALESCE(("+state+") <> 'hidden', true)") + } + references = append(references, + fmt.Sprintf("(n.reason <> 'mention' OR %s = 'social.coves.community.comment' OR "+postCollection+")", recordCollection, "n.record_uri"), + fmt.Sprintf("(n.reason <> 'upvote' OR %s = 'social.coves.community.comment' OR "+postCollection+")", subjectCollection, "n.subject_uri")) + visibility.referencesVisible = strings.Join(references, " AND ") + conditions := []string{visibility.referencesVisible, + `(n.actor_did IS NULL OR NOT EXISTS ( + SELECT 1 FROM user_blocks b WHERE + (b.blocker_did = n.recipient_did AND b.blocked_did = n.actor_did) + OR (b.blocker_did = n.actor_did AND b.blocked_did = n.recipient_did)))`, + "(n.reason <> 'upvote' OR " + upvoteGroupAliveSQL("n.subject_uri", "n.recipient_did", bridgedTotals) + ")", + `NOT EXISTS (SELECT 1 FROM notification_state ps + WHERE ps.did = n.recipient_did AND n.reason = ANY(ps.disabled_reasons))`} + visibility.visible = strings.Join(conditions, " AND ") + return visibility +} + +// moderatedState renders the state of otherwise-live content whose removal is +// decided by any of the comma-separated uriExprs: one scalar aggregate over +// their active removal decisions, so several decisions never multiply rows. +// bool_or over no rows is NULL, which reads as live. +func moderatedState(uriExprs string) string { + return `(SELECT CASE bool_or(d.scope_kind = 'instance') + WHEN true THEN 'removedByServerAdmin' WHEN false THEN 'removedByModerator' ELSE 'live' END + FROM moderation_decisions d + WHERE d.subject_uri IN (` + uriExprs + `) AND d.kind = 'removal' AND d.active)` +} + +// Gate-off statement for existing plan tests; runtime uses r.countUnreadSQL. +var countUnreadNotificationsSQL = buildCountUnreadNotificationsSQL(false) + +// buildCountUnreadNotificationsSQL bounds sort_at by seen_at as an index +// condition so the recipient index scan stops at the read boundary instead of +// walking the read history. sort_at is NOT NULL, so a NULL or absent seen_at +// ('-infinity') treats every row as unread; the LIMIT then counts only the +// newest visible row. +func buildCountUnreadNotificationsSQL(bridgedTotals bool) string { + visibility := notificationVisibility(bridgedTotals) + return `SELECT COUNT(*) FROM ( + SELECT n.id FROM notifications n` + visibility.joins + ` + WHERE n.recipient_did = $1 + AND n.sort_at > COALESCE((SELECT seen_at FROM notification_state WHERE did = $1), '-infinity'::timestamptz) + AND ` + visibility.visible + ` + ORDER BY n.sort_at DESC, n.id DESC + LIMIT CASE WHEN (SELECT seen_at FROM notification_state WHERE did = $1) IS NULL THEN 1 ELSE 101 END + ) visible_unread` +} + +// CountUnread counts at most 101 visible unread notifications for the recipient. +func (r *postgresNotificationRepo) CountUnread(ctx context.Context, recipientDID string) (int, error) { + var count int + if err := r.db.QueryRowContext(ctx, r.countUnreadSQL, recipientDID).Scan(&count); err != nil { + return 0, fmt.Errorf("count unread notifications: %w", err) + } + return count, nil +} diff --git a/internal/db/postgres/post_visibility.go b/internal/db/postgres/post_visibility.go index a3ebcf1..6653bdf 100644 --- a/internal/db/postgres/post_visibility.go +++ b/internal/db/postgres/post_visibility.go @@ -123,8 +123,10 @@ func visiblePostsPredicate(viewerExpr string) (joinSQL, whereSQL string) { // WITHOUT the active-removal exclusion. visiblePostsPredicate is this plus that // exclusion, so the two cannot disagree about admission. // -// Its only other caller is post.get's #moderatedPost gate -// (AdmittedURIsForViewer): an instance removal may tell a viewer that a post +// Its other callers need admission regardless of removal: the notification +// reference states and withdrawal markers (a removed post that was public still +// reads as removed rather than hidden), and post.get's #moderatedPost gate +// (AdmittedURIsForViewer), where an instance removal may tell a viewer that a post // was removed only if that viewer could have seen the post had it not been // removed. Anything else would disclose a pending, rejected or unadmitted post // and tie it to a community that never accepted it. The public moderation log diff --git a/internal/db/postgres/read_path_indexes_migration_test.go b/internal/db/postgres/read_path_indexes_migration_test.go index d838886..097c612 100644 --- a/internal/db/postgres/read_path_indexes_migration_test.go +++ b/internal/db/postgres/read_path_indexes_migration_test.go @@ -37,6 +37,12 @@ func TestMigration049ReadPathIndexes(t *testing.T) { } assertReadPathIndexDefinitions(t, db, "after 049 Up") + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), diff --git a/internal/db/postgres/rematerialize_ledger_schema_test.go b/internal/db/postgres/rematerialize_ledger_schema_test.go index bf31b36..e8cc17a 100644 --- a/internal/db/postgres/rematerialize_ledger_schema_test.go +++ b/internal/db/postgres/rematerialize_ledger_schema_test.go @@ -196,6 +196,12 @@ func TestRematerializeLedgerMigration_RollsBack(t *testing.T) { // 037 and come off first, one asserted step at a time. Asserting which // migration rolled back is what keeps this pointed at 037's Down rather than // drifting onto a newer one later. + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), diff --git a/internal/db/postgres/user_repo.go b/internal/db/postgres/user_repo.go index d40fb5f..c81ca1d 100644 --- a/internal/db/postgres/user_repo.go +++ b/internal/db/postgres/user_repo.go @@ -1,7 +1,6 @@ package postgres import ( - "Coves/internal/core/users" "context" "database/sql" "errors" @@ -9,6 +8,8 @@ import ( "log/slog" "strings" + "Coves/internal/core/users" + "github.com/lib/pq" ) @@ -284,7 +285,7 @@ func (r *postgresUserRepo) Delete(ctx context.Context, did string) error { } // Start transaction for atomic deletion - tx, err := r.db.BeginTx(ctx, nil) + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return fmt.Errorf("failed to start transaction for did=%s: %w", did, err) } @@ -297,10 +298,18 @@ func (r *postgresUserRepo) Delete(ctx context.Context, did string) error { } }() + // Notification fan-out takes the shared erasure lock for actor_did before + // checking deleted_accounts, since actors have no foreign key. Taking the + // exclusive lock before the marker means fan-out either finishes before + // erasure starts or sees the marker. + if _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock("+ErasureLockKeySQL+")", did); err != nil { + return fmt.Errorf("failed to take erasure lock for did=%s: %w", did, err) + } + // 0. Record the erasure marker (migration 036). // - // It goes FIRST and inside this transaction, both deliberately. Inside, - // because a marker that survived a rolled-back deletion would name an + // It is the first write after the lock and inside this transaction, both + // deliberately. Inside, because a marker that survived a rolled-back deletion would name an // account that still exists — and the ingestion gate reads this table, so // that account's future posts would be dropped forever with no row // anywhere explaining it. First, because every statement below erases @@ -399,7 +408,29 @@ func (r *postgresUserRepo) Delete(ctx context.Context, did string) error { return fmt.Errorf("failed to delete posts for did=%s: %w", did, err) } - // 11. Delete user + // 11. Delete notifications for this recipient or actor after content deletes. + // A notification consumer locks the recipient's content row (count/reply_count + // UPDATE) before upserting the notification. Deleting notifications first + // could hold the notification row it needs while waiting for the content row + // it holds, causing a deadlock. Upvote groups this user voted into have a + // NULL actor_did and are deliberately left in place; read-time rules hide + // groups that lose their last voter. + if _, err := tx.ExecContext(ctx, `DELETE FROM notifications WHERE recipient_did = $1 OR actor_did = $1`, did); err != nil { + return fmt.Errorf("failed to delete notifications for did=%s: %w", did, err) + } + // The split_part expression is served by migration 055's expression index; + // keep the two identical. + if _, err := tx.ExecContext(ctx, `DELETE FROM notification_public_post_withdrawals + WHERE split_part(post_uri, '/', 3) = $1`, did); err != nil { + return fmt.Errorf("failed to delete notification public post withdrawals for did=%s: %w", did, err) + } + + // 12. Delete notification state + if _, err := tx.ExecContext(ctx, `DELETE FROM notification_state WHERE did = $1`, did); err != nil { + return fmt.Errorf("failed to delete notification_state for did=%s: %w", did, err) + } + + // 13. Delete user result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE did = $1`, did) if err != nil { return fmt.Errorf("failed to delete user did=%s: %w", did, err) diff --git a/internal/db/postgres/vote_drift_recount_migration_test.go b/internal/db/postgres/vote_drift_recount_migration_test.go index 8403e52..2243fa1 100644 --- a/internal/db/postgres/vote_drift_recount_migration_test.go +++ b/internal/db/postgres/vote_drift_recount_migration_test.go @@ -80,6 +80,12 @@ func TestMigration040_RecountsVoteDriftAndSweepsLegacyOrphans(t *testing.T) { // point of a repair migration and cannot be observed by seeding after it has // run. Asserting the version that came off is the tripwire that keeps this // pointed at 040 when later migrations land. + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "054 (upvote history index) must be rolled back before testing earlier migrations") + require.EqualValues(t, 53, testkit.MigrateDownOne(t, db, 53), + "053 (notifications) must be rolled back before testing earlier migrations") require.EqualValues(t, 52, testkit.MigrateDownOne(t, db, 52), "052 (moderation CDN purge targets) must be rolled back before testing earlier migrations") require.EqualValues(t, 51, testkit.MigrateDownOne(t, db, 51), diff --git a/internal/db/postgres/votes_upvote_history_index_migration_test.go b/internal/db/postgres/votes_upvote_history_index_migration_test.go new file mode 100644 index 0000000..da9b0bd --- /dev/null +++ b/internal/db/postgres/votes_upvote_history_index_migration_test.go @@ -0,0 +1,58 @@ +//go:build integration + +package postgres + +import ( + "context" + "database/sql" + "testing" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestVotesUpvoteHistoryIndexMigration054_DownAndUpRoundTrip(t *testing.T) { + t.Parallel() + db := testkit.DB(t) + + assertVotesUpvoteHistoryIndex(t, db, "after 054 Up") + require.EqualValues(t, 55, testkit.MigrateDownOne(t, db, 55), + "055 (notification public post withdrawals) must be rolled back before testing earlier migrations") + require.EqualValues(t, 54, testkit.MigrateDownOne(t, db, 54), + "this must exercise 054's Down section, not an earlier migration") + require.False(t, indexExists(t, db, "idx_votes_voter_subject_upvotes"), + "054 Down must remove the upvote history index") + + testkit.MigrateUp(t, db) + assertVotesUpvoteHistoryIndex(t, db, "after reapplying 054") +} + +func assertVotesUpvoteHistoryIndex(t *testing.T, db *sql.DB, stage string) { + t.Helper() + var valid, ready, unique bool + var keyCount, attributeCount int + var firstColumn, secondColumn, predicate string + err := db.QueryRowContext(context.Background(), ` + SELECT i.indisvalid, i.indisready, i.indisunique, i.indnkeyatts, i.indnatts, + pg_get_indexdef(i.indexrelid, 1, true), pg_get_indexdef(i.indexrelid, 2, true), + pg_get_expr(i.indpred, i.indrelid) + FROM pg_index i + JOIN pg_class index_class ON index_class.oid = i.indexrelid + JOIN pg_class table_class ON table_class.oid = i.indrelid + JOIN pg_namespace n ON n.oid = table_class.relnamespace + WHERE n.nspname = 'public' AND table_class.relname = 'votes' + AND index_class.relname = 'idx_votes_voter_subject_upvotes' + `).Scan(&valid, &ready, &unique, &keyCount, &attributeCount, + &firstColumn, &secondColumn, &predicate) + require.NoErrorf(t, err, "%s: reading idx_votes_voter_subject_upvotes on votes", stage) + require.True(t, valid, stage+": index must be valid") + require.True(t, ready, stage+": index must be ready for inserts") + require.False(t, unique, stage+": index must not be unique") + require.Equal(t, 2, keyCount, stage+": exactly two key columns") + require.Equal(t, 2, attributeCount, stage+": no included columns") + require.Equal(t, "voter_did", firstColumn, stage+": first key column") + require.Equal(t, "subject_uri", secondColumn, stage+": second key column") + require.Equal(t, "(direction = 'up'::text)", predicate, + stage+": deleted upvotes must remain indexed for history lookups") +} diff --git a/scripts/publish-lexicon-dns.sh b/scripts/publish-lexicon-dns.sh index b47fd7e..424ea4a 100755 --- a/scripts/publish-lexicon-dns.sh +++ b/scripts/publish-lexicon-dns.sh @@ -5,7 +5,7 @@ # Usage: # CF_API_TOKEN= \ # LEXICON_DID=did:plc:xxxxxxxxxxxx \ -# ./scripts/publish-lexicon-dns.sh [--include-moderation] +# ./scripts/publish-lexicon-dns.sh [--include-moderation] [--include-notification] # # Idempotent: existing records are updated in place, missing ones created. set -euo pipefail @@ -41,9 +41,13 @@ AUTHORITIES=( "_lexicon.vote.feed.${ZONE_NAME}" "_lexicon.richtext.${ZONE_NAME}" ) -if [[ "${1:-}" == "--include-moderation" ]]; then - AUTHORITIES+=("_lexicon.moderation.${ZONE_NAME}") -fi +for FLAG in "$@"; do + case "$FLAG" in + --include-moderation) AUTHORITIES+=("_lexicon.moderation.${ZONE_NAME}") ;; + --include-notification) AUTHORITIES+=("_lexicon.notification.${ZONE_NAME}") ;; + *) echo "error: unknown flag ${FLAG}" >&2; exit 1 ;; + esac +done API="https://api.cloudflare.com/client/v4" AUTH=(-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json") diff --git a/tests/e2e/notification_auth_contract_test.go b/tests/e2e/notification_auth_contract_test.go new file mode 100644 index 0000000..14e9768 --- /dev/null +++ b/tests/e2e/notification_auth_contract_test.go @@ -0,0 +1,69 @@ +//go:build e2e + +package e2e + +import ( + "context" + "net/http" + "testing" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +func TestNotificationQueryAuthContract(t *testing.T) { + p := newPipeline(t) + + for _, nsid := range []string{"social.coves.notification.getUnreadCount"} { + t.Run(nsid, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), contractBudget) + defer cancel() + err := p.AppView.Query(ctx, nsid, nil, nil) + require.Truef(t, testkit.IsStatus(err, http.StatusUnauthorized), + "%s must answer 401 to a client with no session; answered: %v", nsid, err) + }) + } +} + +func TestNotificationPreferencesAuthContract(t *testing.T) { + p := newPipeline(t) + + t.Run("social.coves.notification.getPreferences", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), contractBudget) + defer cancel() + const nsid = "social.coves.notification.getPreferences" + err := p.AppView.Query(ctx, nsid, nil, nil) + require.Truef(t, testkit.IsStatus(err, http.StatusUnauthorized), + "%s must answer 401 to a client with no session; answered: %v", nsid, err) + }) + + t.Run("social.coves.notification.putPreferences", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), contractBudget) + defer cancel() + const nsid = "social.coves.notification.putPreferences" + err := p.AppView.Procedure(ctx, nsid, map[string]any{"mention": false}, nil) + require.Truef(t, testkit.IsStatus(err, http.StatusUnauthorized), + "%s must answer 401 to a client with no session; answered: %v", nsid, err) + }) +} + +func TestNotificationUpdateSeenAuthContract(t *testing.T) { + p := newPipeline(t) + ctx, cancel := context.WithTimeout(context.Background(), contractBudget) + defer cancel() + const nsid = "social.coves.notification.updateSeen" + err := p.AppView.Procedure(ctx, nsid, map[string]any{"seenAt": "2026-09-30T12:00:00Z"}, nil) + require.Truef(t, testkit.IsStatus(err, http.StatusUnauthorized), + "%s must answer 401 to a client with no session; answered: %v", nsid, err) +} + +func TestNotificationListAuthContract(t *testing.T) { + p := newPipeline(t) + ctx, cancel := context.WithTimeout(context.Background(), contractBudget) + defer cancel() + const nsid = "social.coves.notification.listNotifications" + err := p.AppView.Query(ctx, nsid, nil, nil) + require.Truef(t, testkit.IsStatus(err, http.StatusUnauthorized), + "%s must answer 401 to a client with no session; answered: %v", nsid, err) +} diff --git a/tests/e2e/notification_contract_test.go b/tests/e2e/notification_contract_test.go new file mode 100644 index 0000000..87e7168 --- /dev/null +++ b/tests/e2e/notification_contract_test.go @@ -0,0 +1,322 @@ +//go:build e2e + +package e2e + +import ( + "context" + "net/url" + "strings" + "testing" + "time" + + "Coves/tests/testkit" + + "github.com/stretchr/testify/require" +) + +// Direct PDS writes prove that replies, mentions and votes reach the recipient's +// notification endpoints through the running consumers. Each positive is awaited +// through listNotifications; the self-upvote negative is bounded by the same +// repo's served vote stats, then held to catch a late notification. + +type notificationContractRow struct { + Reason string `json:"reason"` + SortAt string `json:"sortAt"` + IsRead bool `json:"isRead"` + RootPost struct { + URI string `json:"uri"` + } `json:"rootPost"` + Subject struct { + URI string `json:"uri"` + } `json:"subject"` + Record struct { + URI string `json:"uri"` + } `json:"record"` + Author struct { + DID string `json:"did"` + } `json:"author"` + UpvoteCount int `json:"upvoteCount"` + RecentUpvoters []struct { + DID string `json:"did"` + } `json:"recentUpvoters"` +} + +type notificationContractPage struct { + Notifications []notificationContractRow `json:"notifications"` + SeenAt string `json:"seenAt"` +} + +func notificationContractList(viewer *testkit.AppView) (notificationContractPage, error) { + var page notificationContractPage + err := viewer.Query(context.Background(), "social.coves.notification.listNotifications", + url.Values{"limit": {"100"}}, &page) + return page, err +} + +func notificationContractUnread(viewer *testkit.AppView) (int, error) { + var unread struct { + Count int `json:"count"` + } + err := viewer.Query(context.Background(), "social.coves.notification.getUnreadCount", nil, &unread) + return unread.Count, err +} + +// The probe deliberately reads only the list; each phase checks unread once the +// matching row has arrived, rather than spending the read quota inside Await. +func awaitNotificationContractRow(t *testing.T, p *pipeline, viewer *testkit.AppView, + description string, matches func(notificationContractRow) bool) notificationContractRow { + t.Helper() + var found notificationContractRow + p.Await(t, description, func() (bool, error) { + page, err := notificationContractList(viewer) + if err != nil { + return false, err + } + for _, row := range page.Notifications { + if matches(row) { + found = row + return true, nil + } + } + return false, nil + }) + return found +} + +type notificationContractKey struct { + reason, recordURI, subjectURI string +} + +func notificationContractRows(page notificationContractPage) map[notificationContractKey]int { + rows := make(map[notificationContractKey]int, len(page.Notifications)) + for _, row := range page.Notifications { + rows[notificationContractKey{row.Reason, row.Record.URI, row.Subject.URI}]++ + } + return rows +} + +// Use the original sortAt spelling for updateSeen, even though time parsing is +// necessary to select the newest row (string ordering is not time ordering). +func notificationContractSeen(t *testing.T, viewer *testkit.AppView) { + t.Helper() + before, err := notificationContractList(viewer) + require.NoError(t, err, "the recipient must be able to list the rows being marked seen") + require.NotEmpty(t, before.Notifications, "there must be a notification to mark seen") + + var latest time.Time + var watermark string + for _, row := range before.Notifications { + instant, parseErr := time.Parse(time.RFC3339Nano, row.SortAt) + require.NoErrorf(t, parseErr, "notification sortAt %q must be a timestamp", row.SortAt) + if watermark == "" || instant.After(latest) { + latest, watermark = instant, row.SortAt + } + } + require.NoError(t, viewer.Procedure(context.Background(), "social.coves.notification.updateSeen", + map[string]string{"seenAt": watermark}, nil), + "updateSeen must accept the newest listed sortAt verbatim") + unread, err := notificationContractUnread(viewer) + require.NoError(t, err, "getUnreadCount must answer after updateSeen") + require.Equal(t, 0, unread, "marking the newest listed row seen must clear unread notifications") + + after, err := notificationContractList(viewer) + require.NoError(t, err, "the recipient must be able to re-list after updateSeen") + require.Equal(t, notificationContractRows(before), notificationContractRows(after), + "updateSeen must keep the same rows, identified by reason and record/subject URI") + require.Equal(t, watermark, after.SeenAt, "the list must echo the exact sortAt sent to updateSeen") + for _, row := range after.Notifications { + require.Truef(t, row.IsRead, "notification %s (%s, %s) must be read after updateSeen", + row.Reason, row.Record.URI, row.Subject.URI) + } +} + +func notificationContractMentionFacet(content, handle, did string) []map[string]any { + mention := "@" + handle + start := strings.Index(content, mention) + return []map[string]any{{ + "index": map[string]any{"byteStart": start, "byteEnd": start + len(mention)}, + "features": []map[string]any{{"$type": "social.coves.richtext.facet#mention", "did": did}}, + }} +} + +func notificationContractMentionPost(communityDID, handle, did string) map[string]any { + content := "A post mentioning @" + handle + record := postV2Record(communityDID, "Mention in a post", content) + record["facets"] = notificationContractMentionFacet(content, handle, did) + return record +} + +// Each full-length wait below gets a rate-limit bucket of its own. A healthy +// posts-lane wait runs about 24s under make ci; at contractPollInterval, a +// bucket holding two or three of those plus a phase's reads can pass 100 +// requests in one window and fail as a 429 on a healthy run. One wait plus its +// reads always fits (contractPollInterval). A signed-in viewer copies the +// client IP it was made from, so it is rebuilt after every rotation. +func notificationContractQuota(t *testing.T, p *pipeline, token, reason string) *testkit.AppView { + t.Helper() + p.FreshReadQuota(t, reason) + return p.AppView.As(token) +} + +// notificationContractAcceptedPost is indexedPost with a fresh bucket before +// each of its two status waits. reason must be unique within the test, because +// the bucket is derived from it. +func notificationContractAcceptedPost(t *testing.T, p *pipeline, community provisionedCommunity, + author *testkit.Account, record map[string]any, reason, description string) strongRef { + t.Helper() + rkey := testkit.TID() + uri := authorPostURI(author.DID, rkey) + written := author.PutRecord(t, postV2Collection, rkey, record) + p.FreshReadQuota(t, reason+"-pending") + awaitStatus(t, p, uri, community.DID, "pending", description+" to reach the admission queue") + community.PutRecord(t, acceptanceCollection, subjectRkey(uri), acceptanceRecord(uri, written.CID)) + p.FreshReadQuota(t, reason+"-accepted") + awaitStatus(t, p, uri, community.DID, "accepted", description+" to be accepted") + return strongRef{URI: uri, CID: written.CID} +} + +func TestNotificationContract_Replies(t *testing.T) { + p := newPipeline(t) + a := p.IndexedAccount(t, "nra") + b := p.IndexedAccount(t, "nrb") + community := indexedCommunity(t, p, "nr", b.DID) + token := p.AppView.SignIn(t, b) + + post := notificationContractAcceptedPost(t, p, community, b, + postV2Record(community.DID, "reply to B's post", "a post to hang comments on"), + "b-post", "B's post") + viewer := notificationContractQuota(t, p, token, "post-reply") + firstRkey := testkit.TID() + firstURI := commentURI(a.DID, firstRkey) + a.PutRecord(t, commentCollection, firstRkey, commentRecord(post, post, "reply to B's post")) + row := awaitNotificationContractRow(t, p, viewer, "A's comment to notify the post author B", func(row notificationContractRow) bool { + return row.Reason == "postReply" && row.Record.URI == firstURI + }) + require.Equal(t, post.URI, row.Subject.URI, "postReply must name the replied-to post") + require.Equal(t, a.DID, row.Author.DID, "postReply must attribute A's direct PDS write") + unread, err := notificationContractUnread(viewer) + require.NoError(t, err) + require.Equal(t, 1, unread, "B's first post reply must be unread") + notificationContractSeen(t, viewer) + + // A owns the root post; B owns its indexed parent comment. A's reply + // therefore distinguishes the parent-comment recipient from the root author. + aPost := notificationContractAcceptedPost(t, p, community, a, + postV2Record(community.DID, "A's post for B's parent comment", "a post to hang comments on"), + "a-post", "A's post") + parentRkey := testkit.TID() + parentURI := commentURI(b.DID, parentRkey) + parentRecord := b.PutRecord(t, commentCollection, parentRkey, + commentRecord(aPost, aPost, "B's parent comment")) + p.FreshReadQuota(t, "parent-comment") + p.Await(t, "B's parent comment to be indexed before A replies", func() (bool, error) { + thread, err := p.Thread(context.Background(), aPost.URI, nil) + if done, err := testkit.PendingIfNotFound(err); !done || err != nil { + return done, err + } + _, found := thread.find(parentURI) + return found, nil + }, withReadCadence()) + + viewer = notificationContractQuota(t, p, token, "comment-reply") + replyRkey := testkit.TID() + replyURI := commentURI(a.DID, replyRkey) + a.PutRecord(t, commentCollection, replyRkey, + commentRecord(aPost, strongRef{URI: parentURI, CID: parentRecord.CID}, "A replies to B")) + row = awaitNotificationContractRow(t, p, viewer, "A's reply to notify parent commenter B", func(row notificationContractRow) bool { + return row.Reason == "commentReply" && row.Record.URI == replyURI + }) + require.Equal(t, parentURI, row.Subject.URI, "commentReply must name B's parent comment rather than A's root post") + unread, err = notificationContractUnread(viewer) + require.NoError(t, err) + require.Equal(t, 1, unread, "only the new comment reply must be unread after the prior watermark") + notificationContractSeen(t, viewer) +} + +func TestNotificationContract_Mentions(t *testing.T) { + p := newPipeline(t) + a := p.IndexedAccount(t, "nma") + b := p.IndexedAccount(t, "nmb") + community := indexedCommunity(t, p, "nm", a.DID) + token := p.AppView.SignIn(t, b) + + post := notificationContractAcceptedPost(t, p, community, a, + postV2Record(community.DID, "A's post for a comment mention", "a post to hang comments on"), + "a-post", "A's post") + viewer := notificationContractQuota(t, p, token, "comment-mention") + content := "Hello @" + b.Handle + comment := commentRecord(post, post, content) + comment["facets"] = notificationContractMentionFacet(content, b.Handle, b.DID) + commentRkey := testkit.TID() + mentionURI := commentURI(a.DID, commentRkey) + a.PutRecord(t, commentCollection, commentRkey, comment) + row := awaitNotificationContractRow(t, p, viewer, "A's comment mention to notify B", func(row notificationContractRow) bool { + return row.Reason == "mention" && row.Record.URI == mentionURI + }) + require.Equal(t, mentionURI, row.Record.URI, "the comment mention must refer to A's written comment") + unread, err := notificationContractUnread(viewer) + require.NoError(t, err) + require.Equal(t, 1, unread, "B's comment mention must be unread") + notificationContractSeen(t, viewer) + + postURI := notificationContractAcceptedPost(t, p, community, a, + notificationContractMentionPost(community.DID, b.Handle, b.DID), + "mention-post", "the mention post").URI + viewer = notificationContractQuota(t, p, token, "post-mention") + row = awaitNotificationContractRow(t, p, viewer, "A's accepted post mention to notify B", func(row notificationContractRow) bool { + return row.Reason == "mention" && row.Record.URI == postURI + }) + require.Equal(t, postURI, row.RootPost.URI, "a post mention must have its post as the root") + unread, err = notificationContractUnread(viewer) + require.NoError(t, err) + require.Equal(t, 1, unread, "only the new post mention must be unread after the prior watermark") + notificationContractSeen(t, viewer) +} + +func TestNotificationContract_Upvotes(t *testing.T) { + p := newPipeline(t) + a := p.IndexedAccount(t, "nua") + b := p.IndexedAccount(t, "nub") + community := indexedCommunity(t, p, "nu", b.DID) + token := p.AppView.SignIn(t, b) + + first := notificationContractAcceptedPost(t, p, community, b, + postV2Record(community.DID, "B's first vote target", "a post to hang comments on"), + "first-post", "B's first post") + viewer := notificationContractQuota(t, p, token, "other-upvote") + a.PutRecord(t, voteCollection, testkit.TID(), voteRecord(first, "up")) + row := awaitNotificationContractRow(t, p, viewer, "A's upvote to notify B", func(row notificationContractRow) bool { + return row.Reason == "upvote" && row.Subject.URI == first.URI + }) + require.Equal(t, 1, row.UpvoteCount, "the group must count A's one upvote") + require.Len(t, row.RecentUpvoters, 1, "the group must show its one recent voter") + require.Equal(t, a.DID, row.RecentUpvoters[0].DID, "the upvote group must identify voter A") + unread, err := notificationContractUnread(viewer) + require.NoError(t, err) + require.Equal(t, 1, unread, "A's upvote must be unread by B") + notificationContractSeen(t, viewer) + + second := notificationContractAcceptedPost(t, p, community, b, + postV2Record(community.DID, "B's self-vote target", "a post to hang comments on"), + "second-post", "B's second post") + viewer = notificationContractQuota(t, p, token, "self-upvote") + b.PutRecord(t, voteCollection, testkit.TID(), voteRecord(second, "up")) + awaitStats(t, p, second.URI, "B's self-upvote to reach the served post stats before checking its notification", + func(stats postStats) bool { return stats.Upvotes == 1 }) + p.Holds(t, "B's self-upvote to remain absent from notifications after its vote was indexed", func() (bool, error) { + page, err := notificationContractList(viewer) + if err != nil { + return false, err + } + for _, row := range page.Notifications { + if row.Reason == "upvote" && row.Subject.URI == second.URI { + return false, nil + } + } + unread, err := notificationContractUnread(viewer) + if err != nil { + return false, err + } + return unread == 0, nil + }) +} diff --git a/tests/lexicon_notification_bridged_upvote_test.go b/tests/lexicon_notification_bridged_upvote_test.go new file mode 100644 index 0000000..90d552b --- /dev/null +++ b/tests/lexicon_notification_bridged_upvote_test.go @@ -0,0 +1,29 @@ +package tests + +import ( + "testing" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +func TestNotificationListLexicon_BridgedOnlyUpvoteOutputContract(t *testing.T) { + catalog, recordID, _ := placeholderListLexicon(t) + const root = `"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot"}` + for _, tc := range []struct { + name, subject, aggregate string + }{ + {"live without recent voters", `"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot","preview":"Root"}`, `"upvoteCount":5`}, + {"live with empty recent voters", `"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot","preview":"Root"}`, `"upvoteCount":5,"recentUpvoters":[]`}, + {"deleted placeholder without recent voters", `"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot","status":"deleted"}`, `"upvoteCount":4`}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := `{"notifications":[{"reason":"upvote","sortAt":"2026-09-20T09:00:00Z","isRead":false,` + root + `,` + tc.subject + `,` + tc.aggregate + `}]}` + data, err := atdata.UnmarshalJSON([]byte(fixture)) + require.NoError(t, err) + data["$type"] = recordID + require.NoError(t, lexicon.ValidateRecord(catalog, data, recordID, 0)) + }) + } +} diff --git a/tests/lexicon_notification_list_mention_test.go b/tests/lexicon_notification_list_mention_test.go new file mode 100644 index 0000000..73845e3 --- /dev/null +++ b/tests/lexicon_notification_list_mention_test.go @@ -0,0 +1,83 @@ +package tests + +import ( + "context" + "encoding/json" + "testing" + "time" + + "Coves/internal/core/comments" + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + "Coves/internal/core/users" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +type mentionLexiconReader struct { + notifications.ReadRepository + page notifications.ListPage +} + +func (r mentionLexiconReader) List(context.Context, string, string, int) (notifications.ListPage, error) { + return r.page, nil +} + +type mentionLexiconProfiles struct{} + +func (mentionLexiconProfiles) GetByDIDs(context.Context, []string) (map[string]*users.User, error) { + return map[string]*users.User{}, nil +} + +type mentionLexiconPosts map[string]*posts.PostView + +func (p mentionLexiconPosts) GetViewsByURIs(context.Context, []string, string) (map[string]*posts.PostView, error) { + return p, nil +} + +type mentionLexiconComments map[string]*comments.Comment + +func (c mentionLexiconComments) GetByURIsBatch(context.Context, []string) (map[string]*comments.Comment, error) { + return c, nil +} + +func TestNotificationListLexicon_RenderedMentionsValidate(t *testing.T) { + catalog, recordID, _ := placeholderListLexicon(t) + const root = "at://did:plc:owner/social.coves.community.postv2/root" + const comment = "at://did:plc:actor/social.coves.community.comment/mention" + const post = "at://did:plc:actor/social.coves.community.postv2/mention" + const deleted = "at://did:plc:actor/social.coves.community.postv2/deleted" + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + rows := []struct { + uri, root string + record, rootReference notifications.ListedReference + }{ + {comment, root, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafycomment"}, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyroot"}}, + {post, post, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafypost"}, notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafypost"}}, + {deleted, deleted, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeleted"}, notifications.ListedReference{State: notifications.ReferenceDeleted, CID: "bafydeleted"}}, + } + listed := make([]notifications.ListedNotification, 0, len(rows)) + for index, row := range rows { + listed = append(listed, notifications.ListedNotification{Reason: notifications.ReasonMention, ActorDID: "did:plc:actor", RecordURI: row.uri, RootPostURI: row.root, Record: row.record, RootPost: row.rootReference, RecordCreatedAt: at, SortAt: at.Add(time.Duration(index) * time.Second)}) + } + service := notifications.NewListService(mentionLexiconReader{page: notifications.ListPage{Notifications: listed}}, mentionLexiconProfiles{}, mentionLexiconPosts{ + root: {URI: root, CID: "bafyroot", Record: map[string]interface{}{"title": "Root"}}, + post: {URI: post, CID: "bafypost", Record: map[string]interface{}{"title": "Mentioned"}}, + }, mentionLexiconComments{comment: {URI: comment, CID: "bafycomment", Content: "Hello"}}) + output, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, output.Notifications, 3, "live comment, live post, and post placeholder mentions must be rendered") + for index, row := range output.Notifications { + require.Equal(t, notifications.ReasonMention, row.Reason) + require.Equal(t, rows[index].uri, row.Record.URI) + require.Nil(t, row.Subject) + } + encoded, err := json.Marshal(output) + require.NoError(t, err) + data, err := atdata.UnmarshalJSON(encoded) + require.NoError(t, err) + data["$type"] = recordID + require.NoError(t, lexicon.ValidateRecord(catalog, data, recordID, 0)) +} diff --git a/tests/lexicon_notification_list_placeholder_test.go b/tests/lexicon_notification_list_placeholder_test.go new file mode 100644 index 0000000..cdbd73c --- /dev/null +++ b/tests/lexicon_notification_list_placeholder_test.go @@ -0,0 +1,105 @@ +package tests + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +func placeholderListLexicon(t *testing.T) (*lexicon.BaseCatalog, string, lexicon.SchemaObject) { + t.Helper() + catalog := lexicon.NewBaseCatalog() + require.NoError(t, catalog.LoadDirectory(lexiconDir)) + raw, err := os.ReadFile(filepath.Join(lexiconDir, "social", "coves", "notification", "listNotifications.json")) + require.NoError(t, err) + var document struct { + Defs struct { + Main struct { + Output struct { + Schema json.RawMessage `json:"schema"` + } `json:"output"` + } `json:"main"` + } `json:"defs"` + } + require.NoError(t, json.Unmarshal(raw, &document)) + var output lexicon.SchemaObject + require.NoError(t, json.Unmarshal(document.Defs.Main.Output.Schema, &output)) + const recordID = "test.coves.notification.placeholderListOutput" + addPreferencesRecord(t, catalog, recordID, output) + return catalog, recordID, output +} + +func TestNotificationListLexicon_PlaceholderRepliesValidate(t *testing.T) { + catalog, recordID, output := placeholderListLexicon(t) + // Both reply reasons appear as live and as all-three-reference placeholders. + const fixture = `{"notifications":[ + {"reason":"postReply","sortAt":"2026-09-20T09:04:00Z","isRead":false,"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot1","title":"Root"},"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot1","preview":"Root"},"record":{"uri":"at://did:plc:actor/social.coves.community.comment/livepost","cid":"bafyreply1","createdAt":"2026-09-20T09:04:00Z","excerpt":"Reply"}}, + {"reason":"commentReply","sortAt":"2026-09-20T09:03:00Z","isRead":false,"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot1","title":"Root"},"subject":{"uri":"at://did:plc:owner/social.coves.community.comment/parent","cid":"bafyparent1","preview":"Parent"},"record":{"uri":"at://did:plc:actor/social.coves.community.comment/livecomment","cid":"bafyreply2","createdAt":"2026-09-20T09:03:00Z","excerpt":"Reply"}}, + {"reason":"postReply","sortAt":"2026-09-20T09:02:00Z","isRead":true,"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot2","status":"deleted"},"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot2","status":"deleted"},"record":{"uri":"at://did:plc:actor/social.coves.community.comment/deleted","cid":"bafyreply3","createdAt":"2026-09-20T09:02:00Z","status":"deleted"}}, + {"reason":"commentReply","sortAt":"2026-09-20T09:01:00Z","isRead":true,"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/removed","cid":"bafyroot3","status":"removedByModerator"},"subject":{"uri":"at://did:plc:owner/social.coves.community.comment/deletedparent","cid":"bafyparent2","status":"deleted"},"record":{"uri":"at://did:plc:actor/social.coves.community.comment/removed","cid":"bafyreply4","createdAt":"2026-09-20T09:01:00Z","status":"removedByModerator"}} + ]}` + data, err := atdata.UnmarshalJSON([]byte(fixture)) + require.NoError(t, err) + data["$type"] = recordID + require.NoError(t, lexicon.ValidateRecord(catalog, data, recordID, 0), "live and placeholder reply views must validate") + // Indigo permits unknown properties; acceptance alone cannot establish that + // the three placeholder statuses are part of the published output contract. + items := output.Properties["notifications"].Inner.(lexicon.SchemaArray) + viewDef, err := catalog.Resolve(items.Items.Inner.(lexicon.SchemaRef).Ref) + require.NoError(t, err) + view := viewDef.Def.(lexicon.SchemaObject) + for _, field := range []string{"rootPost", "subject", "record"} { + object := view.Properties[field].Inner.(lexicon.SchemaObject) + require.Contains(t, object.Properties, "status", "%s.status must be declared so clients can consume placeholder replies", field) + } +} + +func TestNotificationListLexicon_PlaceholderStatusRejectsBoolean(t *testing.T) { + catalog, recordID, _ := placeholderListLexicon(t) + for _, field := range []string{"rootPost", "subject", "record"} { + t.Run(field, func(t *testing.T) { + const fixture = `{"notifications":[{"reason":"postReply","sortAt":"2026-09-20T09:00:00Z","isRead":false,"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot1","status":"deleted"},"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot1","status":"deleted"},"record":{"uri":"at://did:plc:actor/social.coves.community.comment/reply","cid":"bafyreply1","createdAt":"2026-09-20T09:00:00Z","status":"deleted"}}]}` + data, err := atdata.UnmarshalJSON([]byte(fixture)) + require.NoError(t, err) + data["$type"] = recordID + row := data["notifications"].([]any)[0].(map[string]any) + row[field].(map[string]any)["status"] = true + require.Error(t, lexicon.ValidateRecord(catalog, data, recordID, 0), "%s.status must be a string", field) + }) + } +} + +func TestNotificationListLexicon_PlaceholderStatusKnownValuesNotEnum(t *testing.T) { + catalog, _, output := placeholderListLexicon(t) + items, ok := output.Properties["notifications"].Inner.(lexicon.SchemaArray) + require.True(t, ok) + itemRef, ok := items.Items.Inner.(lexicon.SchemaRef) + require.True(t, ok) + resolved, err := catalog.Resolve(itemRef.Ref) + require.NoError(t, err) + view, ok := resolved.Def.(lexicon.SchemaObject) + require.True(t, ok) + for _, field := range []string{"rootPost", "subject", "record"} { + t.Run(field, func(t *testing.T) { + object, ok := view.Properties[field].Inner.(lexicon.SchemaObject) + require.True(t, ok) + // Inspect the authored schema to distinguish extensible knownValues from enum. + encoded, err := json.Marshal(object.Properties["status"]) + require.NoError(t, err) + var status struct { + Type string `json:"type"` + KnownValues []string `json:"knownValues"` + Enum []string `json:"enum"` + } + require.NoError(t, json.Unmarshal(encoded, &status)) + require.Equal(t, "string", status.Type) + require.ElementsMatch(t, []string{"deleted", "removedByModerator", "removedByServerAdmin"}, status.KnownValues) + require.Empty(t, status.Enum, "status must allow future values") + }) + } +} diff --git a/tests/lexicon_notification_list_preview_test.go b/tests/lexicon_notification_list_preview_test.go new file mode 100644 index 0000000..7c7e50c --- /dev/null +++ b/tests/lexicon_notification_list_preview_test.go @@ -0,0 +1,90 @@ +package tests + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +func TestNotificationListLexicon_PreviewContract(t *testing.T) { + catalog, recordID, output := placeholderListLexicon(t) + items, ok := output.Properties["notifications"].Inner.(lexicon.SchemaArray) + require.True(t, ok) + viewDef, err := catalog.Resolve(items.Items.Inner.(lexicon.SchemaRef).Ref) + require.NoError(t, err) + view := viewDef.Def.(lexicon.SchemaObject) + for _, reference := range []string{"rootPost", "subject", "record"} { + t.Run(reference+" declarations", func(t *testing.T) { + object := view.Properties[reference].Inner.(lexicon.SchemaObject) + for _, field := range []struct{ name, declaration string }{ + {"labels", `{"type":"ref","ref":"com.atproto.label.defs#selfLabels"}`}, + {"thumbnail", `{"type":"string","format":"uri"}`}, + {"thumbnailAlt", `{"type":"string","maxLength":10000,"maxGraphemes":1000}`}, + } { + require.NotContains(t, object.Required, field.name, "%s.%s must be optional", reference, field.name) + property, exists := object.Properties[field.name] + require.True(t, exists, "%s.%s must be declared", reference, field.name) + encoded, err := json.Marshal(property) + require.NoError(t, err) + var declaration map[string]json.RawMessage + require.NoError(t, json.Unmarshal(encoded, &declaration)) + delete(declaration, "description") + actual, err := json.Marshal(declaration) + require.NoError(t, err) + require.JSONEq(t, field.declaration, string(actual), reference+"."+field.name) + } + }) + } + // These remain separate output rows: a post reply carries the post subject, + // while a post mention carries its post in record, and placeholders carry none. + fixture := `{"notifications":[` + + `{"reason":"postReply","sortAt":"2026-09-20T09:02:00Z","isRead":false,` + + `"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot",` + + `"labels":{"values":[{"val":"unknown-value"},{"val":"` + strings.Repeat("é", 64) + `"},{"val":"nsfw","neg":true}]},` + + `"thumbnail":"https://img.example.test/root","thumbnailAlt":"` + strings.Repeat("👨‍👩‍👧‍👦", 400) + `"},` + + `"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/subject","cid":"bafysubject",` + + `"labels":{"values":[{"val":"unknown-value"},{"val":"` + strings.Repeat("é", 64) + `"},{"val":"nsfw","neg":true}]},` + + `"thumbnail":"https://img.example.test/subject","thumbnailAlt":"` + strings.Repeat("👨‍👩‍👧‍👦", 400) + `"},` + + `"record":{"uri":"at://did:plc:actor/social.coves.community.comment/reply","cid":"bafyreply","createdAt":"2026-09-20T09:02:00Z"}},` + + `{"reason":"mention","sortAt":"2026-09-20T09:01:00Z","isRead":false,` + + `"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot","status":"deleted"},` + + `"record":{"uri":"at://did:plc:actor/social.coves.community.postv2/mention","cid":"bafymention","createdAt":"2026-09-20T09:01:00Z",` + + `"labels":{"values":[{"val":"unknown-value"},{"val":"` + strings.Repeat("é", 64) + `"},{"val":"nsfw","neg":true}]},` + + `"thumbnail":"https://img.example.test/mention","thumbnailAlt":"` + strings.Repeat("👨‍👩‍👧‍👦", 400) + `"}},` + + `{"reason":"postReply","sortAt":"2026-09-20T09:00:00Z","isRead":true,` + + `"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot","status":"removedByModerator"},` + + `"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/subject","cid":"bafysubject","status":"deleted"},` + + `"record":{"uri":"at://did:plc:actor/social.coves.community.comment/reply","cid":"bafyreply","createdAt":"2026-09-20T09:00:00Z","status":"deleted"}}]}` + readFixture := func(t *testing.T) map[string]any { + t.Helper() + data, err := atdata.UnmarshalJSON([]byte(fixture)) + require.NoError(t, err) + data["$type"] = recordID + return data + } + t.Run("schema-valid previews and empty placeholders", func(t *testing.T) { + require.NoError(t, lexicon.ValidateRecord(catalog, readFixture(t), recordID, 0)) + }) + for _, tc := range []struct { + name, field string + value any + }{ + {"eleven self-labels", "labels", map[string]any{"values": []any{ + map[string]any{"val": "one"}, map[string]any{"val": "two"}, map[string]any{"val": "three"}, map[string]any{"val": "four"}, map[string]any{"val": "five"}, map[string]any{"val": "six"}, map[string]any{"val": "seven"}, map[string]any{"val": "eight"}, map[string]any{"val": "nine"}, map[string]any{"val": "ten"}, map[string]any{"val": "eleven"}}}}, + {"129-byte label", "labels", map[string]any{"values": []any{map[string]any{"val": strings.Repeat("é", 64) + "a"}}}}, + {"invalid thumbnail URI", "thumbnail", "not a URI"}, + {"1001 ASCII alt", "thumbnailAlt", strings.Repeat("a", 1001)}, + {"401 family-emoji clusters and 10025 bytes", "thumbnailAlt", strings.Repeat("👨‍👩‍👧‍👦", 401)}, + } { + t.Run(tc.name, func(t *testing.T) { + data := readFixture(t) + row := data["notifications"].([]any)[0].(map[string]any) + row["rootPost"].(map[string]any)[tc.field] = tc.value + require.Error(t, lexicon.ValidateRecord(catalog, data, recordID, 0), "rootPost.%s must reject %s", tc.field, tc.name) + }) + } +} diff --git a/tests/lexicon_notification_list_test.go b/tests/lexicon_notification_list_test.go new file mode 100644 index 0000000..99a8415 --- /dev/null +++ b/tests/lexicon_notification_list_test.go @@ -0,0 +1,171 @@ +package tests + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "Coves/internal/core/notifications" + "Coves/internal/core/posts" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +func TestNotificationListLexicon_Contract(t *testing.T) { + const nsid = "social.coves.notification.listNotifications" + catalog := lexicon.NewBaseCatalog() + require.NoError(t, catalog.LoadDirectory(lexiconDir)) + raw, err := os.ReadFile(filepath.Join(lexiconDir, "social", "coves", "notification", "listNotifications.json")) + require.NoError(t, err, "listNotifications query lexicon must exist") + var doc struct { + ID string `json:"id"` + Defs struct { + Main struct { + Parameters struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"parameters"` + Output struct { + Schema json.RawMessage `json:"schema"` + } `json:"output"` + Errors []struct { + Name string `json:"name"` + } `json:"errors"` + } `json:"main"` + } `json:"defs"` + } + require.NoError(t, json.Unmarshal(raw, &doc)) + require.Equal(t, nsid, doc.ID) + resolved, err := catalog.Resolve(nsid) + require.NoError(t, err) + _, ok := resolved.Def.(lexicon.SchemaQuery) + require.True(t, ok, "listNotifications must be a query, got %T", resolved.Def) + var limit struct { + Type string `json:"type"` + Minimum int `json:"minimum"` + Maximum int `json:"maximum"` + Default int `json:"default"` + } + require.NoError(t, json.Unmarshal(doc.Defs.Main.Parameters.Properties["limit"], &limit)) + require.Equal(t, "integer", limit.Type) + require.Equal(t, 1, limit.Minimum) + require.Equal(t, 100, limit.Maximum) + require.Equal(t, 50, limit.Default) + var cursor struct { + Type string `json:"type"` + } + require.NoError(t, json.Unmarshal(doc.Defs.Main.Parameters.Properties["cursor"], &cursor)) + require.Equal(t, "string", cursor.Type) + var errorNames []string + for _, declared := range doc.Defs.Main.Errors { + errorNames = append(errorNames, declared.Name) + } + require.Contains(t, errorNames, "InvalidCursor") + + var output lexicon.SchemaObject + require.NoError(t, json.Unmarshal(doc.Defs.Main.Output.Schema, &output)) + require.Equal(t, "object", output.Type) + properties := make(map[string]any, len(output.Properties)) + for name, value := range output.Properties { + properties[name] = value + } + assertResponseShapeMatches(t, properties, notifications.ListNotificationsOutput{}) + item, ok := output.Properties["notifications"].Inner.(lexicon.SchemaArray) + require.True(t, ok, "notifications must be an array") + itemRef, ok := item.Items.Inner.(lexicon.SchemaRef) + require.True(t, ok, "notification items must reference notificationView") + require.Equal(t, "social.coves.notification.defs#notificationView", itemRef.Ref) + viewDef, err := catalog.Resolve(itemRef.Ref) + require.NoError(t, err) + viewObject, ok := viewDef.Def.(lexicon.SchemaObject) + require.True(t, ok) + viewProperties := make(map[string]any, len(viewObject.Properties)) + for name, value := range viewObject.Properties { + viewProperties[name] = value + } + assertResponseShapeMatches(t, viewProperties, notifications.NotificationView{}) + authorRef, ok := viewObject.Properties["author"].Inner.(lexicon.SchemaRef) + require.True(t, ok, "author must reference the shared profile view") + require.Equal(t, "social.coves.actor.defs#profileView", authorRef.Ref) + root, ok := viewObject.Properties["rootPost"].Inner.(lexicon.SchemaObject) + require.True(t, ok, "rootPost must be an object") + communityRef, ok := root.Properties["community"].Inner.(lexicon.SchemaRef) + require.True(t, ok, "rootPost.community must reference the shared community view") + require.Equal(t, "social.coves.community.post.defs#communityRef", communityRef.Ref) + for _, nested := range []struct { + field string + response any + }{ + {"rootPost", notifications.RootPostView{}}, + {"subject", notifications.SubjectView{}}, + {"record", notifications.RecordView{}}, + } { + object, ok := viewObject.Properties[nested.field].Inner.(lexicon.SchemaObject) + require.True(t, ok, "%s must be an object", nested.field) + nestedProperties := make(map[string]any, len(object.Properties)) + for name, value := range object.Properties { + nestedProperties[name] = value + } + assertResponseShapeMatches(t, nestedProperties, nested.response) + } + profileDef, err := catalog.Resolve(authorRef.Ref) + require.NoError(t, err) + profileObject, ok := profileDef.Def.(lexicon.SchemaObject) + require.True(t, ok, "profileView must be an object") + profileProperties := make(map[string]any, len(profileObject.Properties)) + for name, value := range profileObject.Properties { + profileProperties[name] = value + } + assertResponseShapeMatches(t, profileProperties, notifications.ProfileView{}) + addPreferencesRecord(t, catalog, "test.coves.notification.listOutput", output) + + name := "Reply Author" + valid := notifications.ListNotificationsOutput{Notifications: []notifications.NotificationView{ + { + Reason: notifications.ReasonPostReply, SortAt: "2026-09-20T09:03:00Z", IsRead: false, + RootPost: ¬ifications.RootPostView{URI: "at://did:plc:owner/social.coves.community.postv2/root", CID: "bafyroot", Title: "Root", Community: &posts.CommunityRef{DID: "did:plc:community", Handle: "community.coves.social", Name: "Community"}}, + Subject: ¬ifications.SubjectView{URI: "at://did:plc:owner/social.coves.community.postv2/root", CID: "bafyroot", Preview: "Root"}, + Record: ¬ifications.RecordView{URI: "at://did:plc:actor/social.coves.community.comment/one", CID: "bafyreplyone", Excerpt: "First reply", CreatedAt: "2026-09-20T09:02:00Z"}, + Author: ¬ifications.ProfileView{DID: "did:plc:actor", Handle: "actor.test", DisplayName: &name}, + }, + { + Reason: notifications.ReasonCommentReply, SortAt: "2026-09-20T09:01:00Z", IsRead: true, + RootPost: ¬ifications.RootPostView{URI: "at://did:plc:owner/social.coves.community.postv2/root", CID: "bafyroot", Title: "Root", Community: &posts.CommunityRef{DID: "did:plc:community", Handle: "community.coves.social", Name: "Community"}}, + Subject: ¬ifications.SubjectView{URI: "at://did:plc:owner/social.coves.community.comment/parent", CID: "bafyparent", Preview: "Parent"}, + Record: ¬ifications.RecordView{URI: "at://did:plc:actor/social.coves.community.comment/two", CID: "bafyreplytwo", Excerpt: "Second reply", CreatedAt: "2026-09-20T09:00:00Z"}, + Author: ¬ifications.ProfileView{DID: "did:plc:unindexed"}, + }, + }, Cursor: "next-page", SeenAt: "2026-09-20T09:00:00Z"} + encoded, err := json.Marshal(valid) + require.NoError(t, err) + data, err := atdata.UnmarshalJSON(encoded) + require.NoError(t, err) + data["$type"] = "test.coves.notification.listOutput" + require.NoError(t, lexicon.ValidateRecord(catalog, data, "test.coves.notification.listOutput", 0)) + + for _, tc := range []struct{ name, field, container string }{ + {"missing reason", "reason", "view"}, + {"missing sortAt", "sortAt", "view"}, + {"missing isRead", "isRead", "view"}, + {"missing rootPost", "rootPost", "view"}, + {"missing record cid", "cid", "record"}, + {"missing subject cid", "cid", "subject"}, + {"missing rootPost cid", "cid", "rootPost"}, + {"missing author did", "did", "author"}, + } { + t.Run(tc.name, func(t *testing.T) { + var fixture map[string]any + require.NoError(t, json.Unmarshal(encoded, &fixture)) + fixture["$type"] = "test.coves.notification.listOutput" + view := fixture["notifications"].([]any)[0].(map[string]any) + if tc.container == "view" { + delete(view, tc.field) + } else { + delete(view[tc.container].(map[string]any), tc.field) + } + require.Error(t, lexicon.ValidateRecord(catalog, fixture, "test.coves.notification.listOutput", 0), "missing %s.%s must be rejected", tc.container, tc.field) + }) + } +} diff --git a/tests/lexicon_notification_list_upvote_test.go b/tests/lexicon_notification_list_upvote_test.go new file mode 100644 index 0000000..02b1e6b --- /dev/null +++ b/tests/lexicon_notification_list_upvote_test.go @@ -0,0 +1,116 @@ +package tests + +import ( + "context" + "encoding/json" + "testing" + "time" + + "Coves/internal/core/notifications" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +func TestNotificationListLexicon_UpvoteOutputContract(t *testing.T) { + t.Run("rendered live and placeholder reasons", assertRenderedUpvoteOutput) + t.Run("invalid aggregates", assertInvalidUpvoteAggregates) +} + +func assertRenderedUpvoteOutput(t *testing.T) { + catalog, recordID, _ := placeholderListLexicon(t) + const root = "at://did:plc:owner/social.coves.community.postv2/root" + const parent = "at://did:plc:owner/social.coves.community.comment/parent" + const reply = "at://did:plc:actor/social.coves.community.comment/reply" + const mentionPost = "at://did:plc:actor/social.coves.community.postv2/mention" + const mentionComment = "at://did:plc:actor/social.coves.community.comment/mention" + at := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + liveRoot := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyroot"} + liveParent := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyparent"} + liveReply := notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafyreply"} + rows := []notifications.ListedNotification{ + {Reason: notifications.ReasonPostReply, ActorDID: "did:plc:actor", RecordURI: reply, Record: liveReply, SubjectURI: root, Subject: liveRoot, RootPostURI: root, RootPost: liveRoot}, + {Reason: notifications.ReasonCommentReply, ActorDID: "did:plc:actor", RecordURI: reply, Record: liveReply, SubjectURI: parent, Subject: liveParent, RootPostURI: root, RootPost: liveRoot}, + {Reason: notifications.ReasonMention, ActorDID: "did:plc:actor", RecordURI: mentionComment, Record: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafymentioncomment"}, RootPostURI: root, RootPost: liveRoot}, + {Reason: notifications.ReasonMention, ActorDID: "did:plc:actor", RecordURI: mentionPost, Record: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafymentionpost"}, RootPostURI: mentionPost, RootPost: notifications.ListedReference{State: notifications.ReferenceLive, CID: "bafymentionpost"}}, + {Reason: notifications.ReasonUpvote, SubjectURI: root, Subject: liveRoot, RootPostURI: root, RootPost: liveRoot, UpvoteCount: 2, RecentUpvoterDIDs: []string{"did:plc:indexed", "did:plc:unindexed"}}, + {Reason: notifications.ReasonUpvote, SubjectURI: parent, Subject: liveParent, RootPostURI: root, RootPost: liveRoot, UpvoteCount: 1, RecentUpvoterDIDs: []string{"did:plc:unindexed"}}, + } + for index := range rows { + rows[index].RecordCreatedAt = at + rows[index].SortAt = at.Add(time.Duration(index) * time.Second) + } + for _, original := range append([]notifications.ListedNotification(nil), rows...) { + placeholder := original + placeholder.RootPost.State = notifications.ReferenceDeleted + if placeholder.Reason == notifications.ReasonMention { + placeholder.Record.State = notifications.ReferenceDeleted + } else { + placeholder.Subject.State = notifications.ReferenceDeleted + if placeholder.Reason != notifications.ReasonUpvote { + placeholder.Record.State = notifications.ReferenceDeleted + } + } + rows = append(rows, placeholder) + } + service := notifications.NewListService(mentionLexiconReader{page: notifications.ListPage{Notifications: rows}}, mentionLexiconProfiles{}, mentionLexiconPosts{ + root: {URI: root, CID: "bafyroot", Record: map[string]interface{}{"title": "Root"}}, + mentionPost: {URI: mentionPost, CID: "bafymentionpost", Record: map[string]interface{}{"title": "Mention"}}, + }, mentionLexiconComments{ + parent: {URI: parent, CID: "bafyparent", Content: "Parent"}, + reply: {URI: reply, CID: "bafyreply", Content: "Reply"}, + mentionComment: {URI: mentionComment, CID: "bafymentioncomment", Content: "Mention"}, + }) + output, err := service.ListNotifications(context.Background(), "did:plc:owner", "", 50) + require.NoError(t, err) + require.Len(t, output.Notifications, 12, "six live and six placeholder rows must render") + encoded, err := json.Marshal(output) + require.NoError(t, err) + var wire struct { + Notifications []map[string]json.RawMessage `json:"notifications"` + } + require.NoError(t, json.Unmarshal(encoded, &wire)) + for _, index := range []int{4, 5, 10, 11} { + require.Contains(t, wire.Notifications[index], "upvoteCount") + require.Contains(t, wire.Notifications[index], "recentUpvoters") + } + data, err := atdata.UnmarshalJSON(encoded) + require.NoError(t, err) + data["$type"] = recordID + require.NoError(t, lexicon.ValidateRecord(catalog, data, recordID, 0)) +} + +func assertInvalidUpvoteAggregates(t *testing.T) { + catalog, recordID, _ := placeholderListLexicon(t) + const row = `{"notifications":[{"reason":"upvote","sortAt":"2026-09-20T09:00:00Z","isRead":false,"rootPost":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot"},"subject":{"uri":"at://did:plc:owner/social.coves.community.postv2/root","cid":"bafyroot"},"upvoteCount":2,"recentUpvoters":[{"did":"did:plc:voter"}]}]}` + valid, err := atdata.UnmarshalJSON([]byte(row)) + require.NoError(t, err) + valid["$type"] = recordID + require.NoError(t, lexicon.ValidateRecord(catalog, valid, recordID, 0), "unchanged upvote row must validate") + for _, tc := range []struct { + name string + change func(map[string]any) + }{ + {"negative count", func(view map[string]any) { view["upvoteCount"] = -1 }}, + {"string count", func(view map[string]any) { view["upvoteCount"] = "2" }}, + {"four voters", func(view map[string]any) { + view["recentUpvoters"] = []any{ + map[string]any{"did": "did:plc:v1"}, map[string]any{"did": "did:plc:v2"}, + map[string]any{"did": "did:plc:v3"}, map[string]any{"did": "did:plc:v4"}, + } + }}, + {"voter without DID", func(view map[string]any) { + view["recentUpvoters"] = []any{map[string]any{"handle": "voter.test"}} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := atdata.UnmarshalJSON([]byte(row)) + require.NoError(t, err) + data["$type"] = recordID + view := data["notifications"].([]any)[0].(map[string]any) + tc.change(view) + require.Error(t, lexicon.ValidateRecord(catalog, data, recordID, 0), "invalid upvote aggregate must be rejected") + }) + } +} diff --git a/tests/lexicon_notification_preferences_test.go b/tests/lexicon_notification_preferences_test.go new file mode 100644 index 0000000..6b08907 --- /dev/null +++ b/tests/lexicon_notification_preferences_test.go @@ -0,0 +1,163 @@ +package tests + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "Coves/internal/api/handlers/notification" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +const preferencesDefinition = "social.coves.notification.defs#preferences" + +type preferencesEndpointDocument struct { + ID string `json:"id"` + Defs struct { + Main struct { + Input lexicon.SchemaBody `json:"input"` + Output lexicon.SchemaBody `json:"output"` + } `json:"main"` + } `json:"defs"` +} + +func readPreferencesEndpoint(t *testing.T, nsid string) preferencesEndpointDocument { + t.Helper() + path := filepath.Join(lexiconDir, "social", "coves", "notification", strings.TrimPrefix(nsid, "social.coves.notification.")+".json") + raw, err := os.ReadFile(path) + require.NoError(t, err, "reading %s lexicon at %s", nsid, path) + var doc preferencesEndpointDocument + require.NoError(t, json.Unmarshal(raw, &doc), "parsing %s lexicon", nsid) + require.Equal(t, nsid, doc.ID, "%s lexicon ID", nsid) + return doc +} + +// Indigo validates records, not endpoint bodies. Wrap the endpoint's resolved +// output object in a temporary record, as unreadOutputCatalog does. +func preferencesOutputCatalog(t *testing.T, catalog *lexicon.BaseCatalog, doc preferencesEndpointDocument) (lexicon.SchemaObject, string) { + t.Helper() + require.NotNil(t, doc.Defs.Main.Output.Schema, "%s output schema", doc.ID) + ref, ok := doc.Defs.Main.Output.Schema.Inner.(lexicon.SchemaRef) + require.True(t, ok, "%s output must reference %s", doc.ID, preferencesDefinition) + resolvedRef := ref.Ref + if strings.HasPrefix(resolvedRef, "#") { + resolvedRef = doc.ID + resolvedRef + } else { + require.Equal(t, preferencesDefinition, resolvedRef, "%s output reference", doc.ID) + } + resolved, err := catalog.Resolve(resolvedRef) + require.NoError(t, err, "resolving %s output reference %s", doc.ID, resolvedRef) + canonical, err := catalog.Resolve(preferencesDefinition) + require.NoError(t, err, "resolving %s", preferencesDefinition) + require.Equal(t, canonical.Def, resolved.Def, "%s output must resolve to the preferences definition", doc.ID) + object, ok := resolved.Def.(lexicon.SchemaObject) + require.True(t, ok, "%s output reference must resolve to an object, got %T", doc.ID, resolved.Def) + recordID := "test.coves.notification." + strings.TrimPrefix(doc.ID, "social.coves.notification.") + "Output" + addPreferencesRecord(t, catalog, recordID, object) + return object, recordID +} + +func addPreferencesRecord(t *testing.T, catalog *lexicon.BaseCatalog, id string, object lexicon.SchemaObject) { + t.Helper() + require.NoError(t, catalog.AddSchemaFile(lexicon.SchemaFile{ + Lexicon: 1, + ID: id, + Defs: map[string]lexicon.SchemaDef{ + "main": {Inner: lexicon.SchemaRecord{Type: "record", Key: "any", Record: object}}, + }, + }), "wrapping %s for record validation", id) +} + +func TestNotificationPreferencesLexicon_Contract(t *testing.T) { + catalog := lexicon.NewBaseCatalog() + require.NoError(t, catalog.LoadDirectory(lexiconDir)) + + for _, endpoint := range []struct { + nsid string + query bool + }{ + {"social.coves.notification.getPreferences", true}, + {"social.coves.notification.putPreferences", false}, + } { + t.Run(endpoint.nsid, func(t *testing.T) { + method, err := catalog.Resolve(endpoint.nsid) + require.NoError(t, err, "%s lexicon must resolve", endpoint.nsid) + if endpoint.query { + _, ok := method.Def.(lexicon.SchemaQuery) + require.True(t, ok, "%s must be a query, got %T", endpoint.nsid, method.Def) + } else { + _, ok := method.Def.(lexicon.SchemaProcedure) + require.True(t, ok, "%s must be a procedure, got %T", endpoint.nsid, method.Def) + } + + doc := readPreferencesEndpoint(t, endpoint.nsid) + output, recordID := preferencesOutputCatalog(t, catalog, doc) + properties := make(map[string]any, len(output.Properties)) + for key, property := range output.Properties { + properties[key] = property + } + assertResponseShapeMatches(t, properties, notification.PreferencesResponse{}) + + for _, tc := range []struct { + name string + data map[string]any + valid bool + }{ + {"all booleans", map[string]any{"postReply": true, "commentReply": false, "mention": true, "upvote": false}, true}, + {"missing upvote", map[string]any{"postReply": true, "commentReply": false, "mention": true}, false}, + {"string mention", map[string]any{"postReply": true, "commentReply": false, "mention": "true", "upvote": false}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + tc.data["$type"] = recordID + err := lexicon.ValidateRecord(catalog, tc.data, recordID, 0) + if tc.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + + raw, err := json.Marshal(notification.PreferencesResponse{PostReply: true, CommentReply: false, Mention: true, Upvote: false}) + require.NoError(t, err) + decoded, err := atdata.UnmarshalJSON(raw) + require.NoError(t, err) + decoded["$type"] = recordID + require.NoError(t, lexicon.ValidateRecord(catalog, decoded, recordID, 0), + "%s: emitted Go response must validate against the output lexicon", endpoint.nsid) + + if endpoint.query { + return + } + require.NotNil(t, doc.Defs.Main.Input.Schema, "%s input schema", endpoint.nsid) + input, ok := doc.Defs.Main.Input.Schema.Inner.(lexicon.SchemaObject) + require.True(t, ok, "%s input must be an inline object, got %T", endpoint.nsid, doc.Defs.Main.Input.Schema.Inner) + const inputRecordID = "test.coves.notification.putPreferencesInput" + addPreferencesRecord(t, catalog, inputRecordID, input) + for _, tc := range []struct { + name string + data map[string]any + valid bool + }{ + {"one preference", map[string]any{"mention": false}, true}, + {"empty update", map[string]any{}, true}, + {"string mention", map[string]any{"mention": "false"}, false}, + } { + t.Run("input "+tc.name, func(t *testing.T) { + tc.data["$type"] = inputRecordID + err := lexicon.ValidateRecord(catalog, tc.data, inputRecordID, 0) + if tc.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + }) + } +} diff --git a/tests/lexicon_notification_test.go b/tests/lexicon_notification_test.go new file mode 100644 index 0000000..87f017f --- /dev/null +++ b/tests/lexicon_notification_test.go @@ -0,0 +1,106 @@ +package tests + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "Coves/internal/api/handlers/notification" + + "github.com/bluesky-social/indigo/atproto/atdata" + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const unreadOutputRecordID = "test.coves.notification.getUnreadCountOutput" + +// Indigo validates records but has no public object validator. Wrap the query's +// actual output object in a temporary record definition in the loaded catalog. +func unreadOutputCatalog(t *testing.T, catalog *lexicon.BaseCatalog) { + t.Helper() + path := filepath.Join(lexiconDir, "social", "coves", "notification", "getUnreadCount.json") + raw, err := os.ReadFile(path) + require.NoError(t, err, "reading getUnreadCount lexicon") + var doc struct { + Defs struct { + Main struct { + Output struct { + Schema lexicon.SchemaObject `json:"schema"` + } `json:"output"` + } `json:"main"` + } `json:"defs"` + } + require.NoError(t, json.Unmarshal(raw, &doc)) + require.Equal(t, "object", doc.Defs.Main.Output.Schema.Type, "getUnreadCount must declare an output object") + properties := make(map[string]any, len(doc.Defs.Main.Output.Schema.Properties)) + for key, value := range doc.Defs.Main.Output.Schema.Properties { + properties[key] = value + } + assertResponseShapeMatches(t, properties, notification.UnreadCountResponse{}) + require.NoError(t, catalog.AddSchemaFile(lexicon.SchemaFile{ + Lexicon: 1, + ID: unreadOutputRecordID, + Defs: map[string]lexicon.SchemaDef{ + "main": {Inner: lexicon.SchemaRecord{ + Type: "record", Key: "any", Record: doc.Defs.Main.Output.Schema, + }}, + }, + })) +} + +func TestNotificationLexicon_Contract(t *testing.T) { + catalog := lexicon.NewBaseCatalog() + require.NoError(t, catalog.LoadDirectory(lexiconDir)) + + t.Run("query", func(t *testing.T) { + query, err := catalog.Resolve("social.coves.notification.getUnreadCount") + require.NoError(t, err) + _, ok := query.Def.(lexicon.SchemaQuery) + require.True(t, ok, "getUnreadCount must be a query, got %T", query.Def) + }) + + t.Run("open reason vocabulary", func(t *testing.T) { + reason, err := catalog.Resolve("social.coves.notification.defs#reason") + require.NoError(t, err) + stringReason, ok := reason.Def.(lexicon.SchemaString) + require.True(t, ok, "reason must be a string, got %T", reason.Def) + assert.ElementsMatch(t, []string{"postReply", "commentReply", "mention", "upvote"}, stringReason.KnownValues) + assert.Empty(t, stringReason.Enum, "reason vocabulary must remain open") + }) + + t.Run("output accepts only bounded required integer count", func(t *testing.T) { + unreadOutputCatalog(t, catalog) + for _, tc := range []struct { + name string + data map[string]any + valid bool + }{ + {"zero", map[string]any{"count": int64(0)}, true}, + {"maximum", map[string]any{"count": int64(101)}, true}, + {"above maximum", map[string]any{"count": int64(102)}, false}, + {"negative", map[string]any{"count": int64(-1)}, false}, + {"string", map[string]any{"count": "3"}, false}, + {"missing", map[string]any{}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + tc.data["$type"] = unreadOutputRecordID + err := lexicon.ValidateRecord(catalog, tc.data, unreadOutputRecordID, 0) + if tc.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + + raw, err := json.Marshal(notification.UnreadCountResponse{Count: 3}) + require.NoError(t, err) + decoded, err := atdata.UnmarshalJSON(raw) + require.NoError(t, err) + decoded["$type"] = unreadOutputRecordID + require.NoError(t, lexicon.ValidateRecord(catalog, decoded, unreadOutputRecordID, 0), + "Go's emitted JSON must validate against the output lexicon") + }) +} diff --git a/tests/lexicon_notification_update_seen_test.go b/tests/lexicon_notification_update_seen_test.go new file mode 100644 index 0000000..4fa76ae --- /dev/null +++ b/tests/lexicon_notification_update_seen_test.go @@ -0,0 +1,52 @@ +package tests + +import ( + "testing" + + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/require" +) + +func TestNotificationUpdateSeenLexicon_Contract(t *testing.T) { + const nsid = "social.coves.notification.updateSeen" + doc := readPreferencesEndpoint(t, nsid) + catalog := lexicon.NewBaseCatalog() + require.NoError(t, catalog.LoadDirectory(lexiconDir)) + resolved, err := catalog.Resolve(nsid) + require.NoError(t, err) + procedure, ok := resolved.Def.(lexicon.SchemaProcedure) + require.True(t, ok, "%s must be a procedure, got %T", nsid, resolved.Def) + require.NotNil(t, procedure.Input) + require.Equal(t, "application/json", procedure.Input.Encoding) + require.Nil(t, procedure.Output, "updateSeen must declare no output") + var errorNames []string + for _, declared := range procedure.Errors { + errorNames = append(errorNames, declared.Name) + } + require.Contains(t, errorNames, "AccountNotIndexed") + + require.NotNil(t, doc.Defs.Main.Input.Schema) + input, ok := doc.Defs.Main.Input.Schema.Inner.(lexicon.SchemaObject) + require.True(t, ok, "%s input must be an object, got %T", nsid, doc.Defs.Main.Input.Schema.Inner) + const recordID = "test.coves.notification.updateSeenInput" + addPreferencesRecord(t, catalog, recordID, input) + for _, tc := range []struct { + name string + data map[string]any + valid bool + }{ + {"valid datetime", map[string]any{"seenAt": "2026-09-30T12:34:56.123456Z"}, true}, + {"missing seenAt", map[string]any{}, false}, + {"invalid datetime", map[string]any{"seenAt": "yesterday"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + tc.data["$type"] = recordID + err := lexicon.ValidateRecord(catalog, tc.data, recordID, 0) + if tc.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +}