Files
silo-server/internal/requests/notify_test.go
QuickandClaude Fable 5 d9e27da59e feat(notifications): request-fulfilled notifications across all channels
Notify the requesting profile once its media request is actually present
in the catalog (roadmap 06, item 2). Completion transitions stay
notification-agnostic; a presence-gated pass at the end of each
reconcile run fires the notice, so it means "watchable in Silo", not
"download finished".

- New System.DispatchOperational: delivery insert + webhook/web-push
  outbox enqueue in one transaction, post-commit multi-dispatch. The
  webhook auto-disable notice now rides the same path (replacing its
  hand-rolled hub publish and the now-removed InsertOperational), which
  also delivers auto-disable notices over web push.
- At-most-once delivery: partial unique index on
  (profile_id, reason_flags->>'request_id') plus a fulfilled_notified_at
  marker on media_requests, backfilled for pre-existing completed
  requests so deploys never flood.
- Per-webhook notify_requests toggle (default on) through repo, service,
  API, and settings UI; gated independently of the episode reason flags.
- request.fulfilled rendering in web inbox, realtime toast, web push
  payload, and Discord/generic webhook payloads, deep-linking to the
  matched catalog item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:04:22 -04:00

102 lines
3.1 KiB
Go

package requests
import (
"context"
"errors"
"testing"
)
type fakeNotifier struct {
requestIDs []string
contentIDs []string
err error
}
func (f *fakeNotifier) NotifyFulfilled(_ context.Context, req Request, contentID string) error {
if f.err != nil {
return f.err
}
f.requestIDs = append(f.requestIDs, req.ID)
f.contentIDs = append(f.contentIDs, contentID)
return nil
}
func completedRequestFixture(id string, tmdbID int) *Request {
return &Request{
ID: id,
MediaType: MediaTypeMovie,
TMDBID: tmdbID,
Title: "Fixture Movie",
Status: StatusCompleted,
Outcome: OutcomeActive,
RequestedByUserID: 7,
RequestedByProfileID: "profile-1",
}
}
func TestNotifyFulfilledPendingNotifiesAndMarks(t *testing.T) {
store := newFakeStore()
store.requests["req1"] = completedRequestFixture("req1", 42)
store.unnotified = []string{"req1"}
presence := &fakePresence{available: map[MediaType]map[int]bool{
MediaTypeMovie: {42: true},
}}
notifier := &fakeNotifier{}
service := NewService(store, &fakeTMDBClient{}, presence)
service.SetFulfillmentNotifier(notifier)
service.notifyFulfilledPending(context.Background())
if len(notifier.requestIDs) != 1 || notifier.requestIDs[0] != "req1" {
t.Fatalf("expected one notification for req1, got %v", notifier.requestIDs)
}
if want := fakePresenceContentID(MediaTypeMovie, 42); notifier.contentIDs[0] != want {
t.Fatalf("expected content id %q, got %q", want, notifier.contentIDs[0])
}
if len(store.notified) != 1 || store.notified[0] != "req1" {
t.Fatalf("expected req1 marked notified, got %v", store.notified)
}
}
func TestNotifyFulfilledPendingWaitsForCatalogMatch(t *testing.T) {
store := newFakeStore()
store.requests["req1"] = completedRequestFixture("req1", 42)
store.unnotified = []string{"req1"}
notifier := &fakeNotifier{}
service := NewService(store, &fakeTMDBClient{}, &fakePresence{})
service.SetFulfillmentNotifier(notifier)
service.notifyFulfilledPending(context.Background())
if len(notifier.requestIDs) != 0 {
t.Fatalf("expected no notification before catalog match, got %v", notifier.requestIDs)
}
if len(store.notified) != 0 {
t.Fatalf("expected request to stay pending, got marked %v", store.notified)
}
if len(store.unnotified) != 1 {
t.Fatalf("expected request to remain in the pending set")
}
}
func TestNotifyFulfilledPendingRetriesAfterNotifierError(t *testing.T) {
store := newFakeStore()
store.requests["req1"] = completedRequestFixture("req1", 42)
store.unnotified = []string{"req1"}
presence := &fakePresence{available: map[MediaType]map[int]bool{
MediaTypeMovie: {42: true},
}}
notifier := &fakeNotifier{err: errors.New("dispatch failed")}
service := NewService(store, &fakeTMDBClient{}, presence)
service.SetFulfillmentNotifier(notifier)
service.notifyFulfilledPending(context.Background())
if len(store.notified) != 0 {
t.Fatalf("a failed dispatch must not mark the request notified, got %v", store.notified)
}
if len(store.unnotified) != 1 {
t.Fatalf("expected request to remain pending for the next run")
}
}