package notify import ( "context" "encoding/json" "errors" "io" "log/slog" "testing" "time" ) func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } type capture struct { records []Record err error } func (c *capture) RecordNotification(_ context.Context, record Record) error { c.records = append(c.records, record) return c.err } func service(t *testing.T, recorder Recorder, fn func(context.Context, Notification) Outcome) *Service { t.Helper() s := New(recorder, quiet()) if fn != nil { s.Register(DelivererFunc{Name: ChannelInApp, Fn: fn}) } return s } func TestSendDeliversThenRecords(t *testing.T) { recorder := &capture{} delivered := false s := service(t, recorder, func(context.Context, Notification) Outcome { // The record must not exist yet: the whole ordering claim is that a notification is // delivered first and described afterwards. if len(recorder.records) != 0 { t.Fatal("the audit trail was written before the notification was delivered") } delivered = true return Sent() }) outcome := s.Send(context.Background(), Notification{ Channel: ChannelInApp, Kind: "show-return", Source: "test", UserID: "u1", Username: "Ada", Title: "New episode coming", }) if !delivered { t.Fatal("the notification was never delivered") } if outcome.Status != StatusSent { t.Fatalf("status = %q, want sent", outcome.Status) } if len(recorder.records) != 1 { t.Fatalf("recorded %d rows, want 1", len(recorder.records)) } record := recorder.records[0] if record.UserID != "u1" || record.Kind != "show-return" || record.Status != StatusSent { t.Fatalf("record = %+v", record) } if record.OccurredAt.IsZero() { t.Fatal("the record carries no timestamp") } } // A history that could suppress a notification would be worse than no history, so a // recorder that will not write must not change what the caller is told. func TestRecorderFailureDoesNotAffectDelivery(t *testing.T) { recorder := &capture{err: errors.New("postgres is down")} s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() }) outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp, UserID: "u1"}) if outcome.Status != StatusSent { t.Fatalf("status = %q, want sent despite the failed write", outcome.Status) } } // The producers here are deliberately re-run — the digest job fires hourly and re-sends the // same weekly key all evening — so a cancelled caller must not be able to lose the record // of the one pass that actually delivered. func TestRecordSurvivesACancelledCaller(t *testing.T) { recorder := &capture{} s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() }) ctx, cancel := context.WithCancel(context.Background()) cancel() s.Send(ctx, Notification{Channel: ChannelInApp, UserID: "u1"}) if len(recorder.records) != 1 { t.Fatalf("recorded %d rows, want 1 from a cancelled caller", len(recorder.records)) } } func TestSkippedIsRecordedWithItsReason(t *testing.T) { recorder := &capture{} s := service(t, recorder, func(context.Context, Notification) Outcome { return Skipped("this viewer has summaries switched off") }) outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp, UserID: "u1"}) if outcome.Status != StatusSkipped { t.Fatalf("status = %q, want skipped", outcome.Status) } if got := recorder.records[0].Detail; got != "this viewer has summaries switched off" { t.Fatalf("detail = %q; a skip with no reason is the row this page exists to avoid", got) } } // A channel with no provider is a configuration fault, and it is worth a row: silence would // look exactly like a household in which nothing happened. func TestMissingProviderIsRecordedAsAFailure(t *testing.T) { recorder := &capture{} s := service(t, recorder, nil) outcome := s.Send(context.Background(), Notification{Channel: ChannelBroadcast, Kind: "x"}) if outcome.Status != StatusFailed { t.Fatalf("status = %q, want failed", outcome.Status) } if len(recorder.records) != 1 || recorder.records[0].Detail == "" { t.Fatalf("records = %+v, want one row naming the missing provider", recorder.records) } } // A nil service is what every unit test of a producing feature holds. It must answer rather // than panic, or every call site grows a nil check — which is how a notification comes to be // silently dropped. func TestNilServiceStillAnswers(t *testing.T) { var s *Service if outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp}); outcome.Status != StatusSkipped { t.Fatalf("status = %q, want skipped from a nil service", outcome.Status) } s.Log(context.Background(), Notification{}, Sent(), 0) s.Register(DelivererFunc{Name: ChannelInApp}) } // A service with no recorder still delivers. This is the shape a gateway built without a // database has, and the shape most feature tests want. func TestNoRecorderStillDelivers(t *testing.T) { delivered := false s := New(nil, quiet()) s.Register(DelivererFunc{Name: ChannelInApp, Fn: func(context.Context, Notification) Outcome { delivered = true return Sent() }}) if s.Send(context.Background(), Notification{Channel: ChannelInApp}).Status != StatusSent { t.Fatal("delivery reported something other than sent") } if !delivered { t.Fatal("the notification was not delivered without a recorder") } } func TestLogRecordsWithoutDelivering(t *testing.T) { recorder := &capture{} called := false s := service(t, recorder, func(context.Context, Notification) Outcome { called = true return Sent() }) s.Log(context.Background(), Notification{ Channel: ChannelWebhook, Kind: "login.failed", Target: "Family Discord", }, Delivered("HTTP 204"), 120*time.Millisecond) if called { t.Fatal("Log delivered the notification; it must only record one somebody else sent") } record := recorder.records[0] if record.Status != StatusDelivered || record.Detail != "HTTP 204" { t.Fatalf("record = %+v", record) } if record.DurationMS != 120 { t.Fatalf("durationMs = %d, want 120", record.DurationMS) } } // The audit trail is rendered in the console, so anything that looks like a credential must // never reach it — regardless of how careful the caller was. func TestRedactDropsCredentialShapedMetadata(t *testing.T) { cleaned := Redact(Notification{Metadata: map[string]any{ "integrationId": "disc-1", "webhookUrl": "https://discord.com/api/webhooks/123/s3cr3t", "apiKey": "abcd", "embyToken": "live-session", "Authorization": "Bearer x", "statusCode": 204, }}) for _, banned := range []string{"webhookUrl", "apiKey", "embyToken", "Authorization"} { if _, present := cleaned.Metadata[banned]; present { t.Errorf("%q survived redaction", banned) } } if cleaned.Metadata["integrationId"] != "disc-1" || cleaned.Metadata["statusCode"] != 204 { t.Fatalf("redaction dropped ordinary context: %+v", cleaned.Metadata) } } func TestSendRedactsBeforeRecording(t *testing.T) { recorder := &capture{} s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() }) s.Send(context.Background(), Notification{ Channel: ChannelInApp, UserID: "u1", Metadata: map[string]any{"series": "The Bear", "webhookUrl": "https://example.test/hook"}, }) var stored map[string]any if err := json.Unmarshal(recorder.records[0].Metadata, &stored); err != nil { t.Fatalf("metadata did not round-trip: %v", err) } if _, present := stored["webhookUrl"]; present { t.Fatal("a credential-shaped key reached the audit trail through Send") } if stored["series"] != "The Bear" { t.Fatalf("stored metadata = %+v", stored) } } // The provider still receives the notification it was handed; redaction is about what is // stored, not about what is delivered. func TestRedactionDoesNotChangeWhatIsDelivered(t *testing.T) { var seen Notification s := service(t, &capture{}, func(_ context.Context, n Notification) Outcome { seen = n return Sent() }) s.Send(context.Background(), Notification{ Channel: ChannelInApp, UserID: "u1", Title: "Your week in Memby", Body: "You watched 4 hours.", }) if seen.Title != "Your week in Memby" || seen.Body != "You watched 4 hours." { t.Fatalf("the provider received %+v", seen) } }