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>
102 lines
3.1 KiB
Go
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")
|
|
}
|
|
}
|