// Package acceptance holds executable acceptance tests that mirror the Gherkin // scenarios in docs/use-cases/. They drive the use-case engine through FAKE // adapters (no live YouTube, no live brain) so the core behaviour is what's // under test. // // These tests are intentionally RED in the scaffold: Engine.ProcessNewVideo // returns usecase.ErrNotImplemented. Making them GREEN is the first build task. package acceptance import ( "context" "testing" "time" "git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/usecase" ) // --- fake adapters --------------------------------------------------------- type fakeSource struct { transcript domain.Transcript } func (f fakeSource) ListSubscriptions(ctx context.Context, userID string) ([]domain.Subscription, error) { return nil, nil } func (f fakeSource) NewVideos(ctx context.Context, sub domain.Subscription) ([]domain.Video, error) { return nil, nil } func (f fakeSource) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) { return f.transcript, nil } type fakeSummarizer struct{} func (fakeSummarizer) Summarize(ctx context.Context, v domain.Video, t domain.Transcript) (domain.Summary, error) { return domain.Summary{ VideoID: v.ID, UserID: v.UserID, Summary: "a summary", Highlights: []string{"h1"}, Takeaways: []string{"t1"}, AIProvider: "local", CreatedAt: time.Now(), }, nil } type recordingSink struct { name string delivered []domain.Summary } func (s *recordingSink) Name() string { return s.name } func (s *recordingSink) Deliver(ctx context.Context, sum domain.Summary) error { s.delivered = append(s.delivered, sum) return nil } func sampleVideo() domain.Video { return domain.Video{ ID: "v1", UserID: "u1", SubscriptionID: "s1", Provider: domain.ProviderYouTube, ProviderVideoID: "yt1", Title: "Designing for Attention", SeenAt: time.Now(), } } // --- scenarios ------------------------------------------------------------- // Scenario: A subscribed channel posts a video that has captions func TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered(t *testing.T) { src := fakeSource{transcript: domain.Transcript{ VideoID: "v1", UserID: "u1", Source: domain.SourceCaptions, Language: "en", Content: "transcript text", }} sink := &recordingSink{name: "store"} eng := usecase.NewEngine(src, fakeSummarizer{}, sink) res, err := eng.ProcessNewVideo(context.Background(), sampleVideo()) if err != nil { t.Fatalf("ProcessNewVideo returned error: %v", err) } if res.Skipped { t.Fatalf("expected video to be summarized, got skipped: %q", res.Reason) } if res.Summary == nil { t.Fatal("expected a summary, got nil") } if len(sink.delivered) != 1 { t.Fatalf("expected 1 delivery to store sink, got %d", len(sink.delivered)) } } // Scenario: A subscribed channel posts a video with no usable transcript func TestVideoWithNoTranscriptIsSkipped(t *testing.T) { src := fakeSource{transcript: domain.Transcript{ VideoID: "v1", UserID: "u1", Source: domain.SourceNone, }} sink := &recordingSink{name: "store"} eng := usecase.NewEngine(src, fakeSummarizer{}, sink) res, err := eng.ProcessNewVideo(context.Background(), sampleVideo()) if err != nil { t.Fatalf("ProcessNewVideo returned error: %v", err) } if !res.Skipped { t.Fatal("expected video to be skipped (no transcript)") } if res.Reason != "no transcript" { t.Fatalf("expected reason %q, got %q", "no transcript", res.Reason) } if res.Summary != nil { t.Fatal("expected no summary for skipped video") } if len(sink.delivered) != 0 { t.Fatalf("expected no summary delivery, got %d", len(sink.delivered)) } } // --- watch-loop fakes ------------------------------------------------------ // watchSource models a provider that knows about videos on several channels but // only surfaces those on channels the user is subscribed to. NewVideos returns // the (possibly repeated) videos for a subscription each time it is polled. type watchSource struct { subs []domain.Subscription videosBySub map[string][]domain.Video transcripts map[string]domain.Transcript // keyed by video ID newVideoHits int } func (s *watchSource) ListSubscriptions(ctx context.Context, userID string) ([]domain.Subscription, error) { return s.subs, nil } func (s *watchSource) NewVideos(ctx context.Context, sub domain.Subscription) ([]domain.Video, error) { s.newVideoHits++ return s.videosBySub[sub.ID], nil } func (s *watchSource) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) { return s.transcripts[v.ID], nil } // countingSummarizer records how many times Summarize is invoked. type countingSummarizer struct{ calls int } func (c *countingSummarizer) Summarize(ctx context.Context, v domain.Video, t domain.Transcript) (domain.Summary, error) { c.calls++ return domain.Summary{VideoID: v.ID, UserID: v.UserID, Summary: "a summary", AIProvider: "local", CreatedAt: time.Now()}, nil } // Scenario: A channel I am not subscribed to posts a video func TestUnsubscribedChannelVideoIsNotProcessed(t *testing.T) { subscribed := domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "acme", ChannelTitle: "Acme Talks", Active: true} acme := domain.Video{ID: "v1", UserID: "u1", SubscriptionID: "s1", Provider: domain.ProviderYouTube, ProviderVideoID: "yt1", Title: "Designing for Attention", SeenAt: time.Now()} // "Random Co" video exists in the wider world but the user is not subscribed, // so its subscription is absent and NewVideos never surfaces it. src := &watchSource{ subs: []domain.Subscription{subscribed}, videosBySub: map[string][]domain.Video{"s1": {acme}}, transcripts: map[string]domain.Transcript{"v1": {VideoID: "v1", UserID: "u1", Source: domain.SourceCaptions, Language: "en", Content: "transcript text"}}, } sum := &countingSummarizer{} sink := &recordingSink{name: "store"} eng := usecase.NewEngine(src, sum, sink) results, err := eng.ProcessNewVideos(context.Background(), "u1") if err != nil { t.Fatalf("ProcessNewVideos returned error: %v", err) } if len(results) != 1 { t.Fatalf("expected exactly 1 video processed (the subscribed one), got %d", len(results)) } if results[0].Video.ID != "v1" { t.Fatalf("expected the subscribed video v1 to be processed, got %q", results[0].Video.ID) } for _, d := range sink.delivered { if d.VideoID == "unrelated" { t.Fatal("unsubscribed channel's video was processed and delivered") } } } // Scenario: The same video is not summarized twice func TestAlreadySummarizedVideoIsNotReprocessed(t *testing.T) { subscribed := domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "acme", ChannelTitle: "Acme Talks", Active: true} video := domain.Video{ID: "v1", UserID: "u1", SubscriptionID: "s1", Provider: domain.ProviderYouTube, ProviderVideoID: "yt1", Title: "Designing for Attention", SeenAt: time.Now()} src := &watchSource{ subs: []domain.Subscription{subscribed}, videosBySub: map[string][]domain.Video{"s1": {video}}, transcripts: map[string]domain.Transcript{"v1": {VideoID: "v1", UserID: "u1", Source: domain.SourceCaptions, Language: "en", Content: "transcript text"}}, } sum := &countingSummarizer{} sink := &recordingSink{name: "store"} eng := usecase.NewEngine(src, sum, sink) // First watch pass summarizes the video. if _, err := eng.ProcessNewVideos(context.Background(), "u1"); err != nil { t.Fatalf("first pass returned error: %v", err) } // The watcher sees the same video again on the next pass. if _, err := eng.ProcessNewVideos(context.Background(), "u1"); err != nil { t.Fatalf("second pass returned error: %v", err) } if sum.calls != 1 { t.Fatalf("expected the video to be summarized exactly once, got %d calls", sum.calls) } if len(sink.delivered) != 1 { t.Fatalf("expected exactly 1 delivery, got %d", len(sink.delivered)) } }