Files
memby/server/internal/notify/notify_test.go
2026-08-19 06:57:59 +12:00

249 lines
8.1 KiB
Go

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)
}
}