0.2.77
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user