// These tests translate docs/use-cases/ai_routing.feature. They drive the // Summarizer through a FAKE Completer — never the live LiteLLM gateway — and the // load-bearing assertion is the local-first guarantee: when no BYO provider is // configured, the transcript content reaches the local stack and nowhere else. package summarizer import ( "context" "errors" "strings" "testing" "gitea.d-ma.be/mathias/tapir/internal/domain" "gitea.d-ma.be/mathias/tapir/internal/ports" ) // compile-time check: Summarizer satisfies the port. var _ ports.Summarizer = (*Summarizer)(nil) // fakeClient records every prompt it is asked to complete, so a test can prove // whether content reached it. It returns reply, or err when err != nil. type fakeClient struct { reply string err error calls int lastUser string } func (f *fakeClient) Complete(_ context.Context, _, user string) (string, error) { f.calls++ f.lastUser = user if f.err != nil { return "", f.err } return f.reply, nil } const goodReply = `{"summary":"A talk about Go.","highlights":["ports and adapters"],"takeaways":["copy, don't couple"]}` func testVideo() domain.Video { return domain.Video{ID: "vid-1", UserID: "user-1", Title: "Clean Architecture in Go", URL: "https://x/y"} } func testTranscript() domain.Transcript { return domain.Transcript{VideoID: "vid-1", UserID: "user-1", Source: domain.SourceCaptions, Content: "secret confidential transcript body"} } // Scenario: Local AI produces the summary. func TestSummarize_LocalSucceeds(t *testing.T) { local := &fakeClient{reply: goodReply} byo := &fakeClient{reply: `{"summary":"should not be used"}`} s := New( Endpoint{Client: local, Provider: "local", Model: "iguana/deepseek-r1-14b"}, &Endpoint{Client: byo, Provider: "anthropic", Model: "claude"}, ) sum, err := s.Summarize(context.Background(), testVideo(), testTranscript()) if err != nil { t.Fatalf("Summarize: %v", err) } if sum.AIProvider != "local" { t.Errorf("AIProvider = %q, want local", sum.AIProvider) } if sum.AIModel != "iguana/deepseek-r1-14b" { t.Errorf("AIModel = %q", sum.AIModel) } if sum.FallbackUsed { t.Error("FallbackUsed = true, want false") } if byo.calls != 0 { t.Errorf("BYO called %d times; must not be touched when local succeeds", byo.calls) } if sum.Summary == "" || len(sum.Highlights) != 1 || len(sum.Takeaways) != 1 { t.Errorf("parsed summary wrong: %+v", sum) } } // Scenario: Local AI fails and the user has a BYO provider configured. func TestSummarize_FallsBackToBYO(t *testing.T) { local := &fakeClient{err: errors.New("connection refused")} byo := &fakeClient{reply: goodReply} s := New( Endpoint{Client: local, Provider: "local", Model: "iguana/deepseek-r1-14b"}, &Endpoint{Client: byo, Provider: "anthropic", Model: "claude-opus"}, ) sum, err := s.Summarize(context.Background(), testVideo(), testTranscript()) if err != nil { t.Fatalf("Summarize: %v", err) } if sum.AIProvider != "anthropic" { t.Errorf("AIProvider = %q, want anthropic", sum.AIProvider) } if sum.AIModel != "claude-opus" { t.Errorf("AIModel = %q, want claude-opus", sum.AIModel) } if !sum.FallbackUsed { t.Error("FallbackUsed = false, want true") } if local.calls != 1 || byo.calls != 1 { t.Errorf("calls: local=%d byo=%d, want 1 and 1", local.calls, byo.calls) } } // Scenario: Local AI fails and the user has no BYO provider. // AND: my content is not sent to any third-party model. func TestSummarize_LocalFailsNoBYO_NoExternalSend(t *testing.T) { local := &fakeClient{err: errors.New("connection refused")} s := New(Endpoint{Client: local, Provider: "local", Model: "iguana/deepseek-r1-14b"}, nil) _, err := s.Summarize(context.Background(), testVideo(), testTranscript()) if err == nil { t.Fatal("want error when local fails and no BYO, got nil") } // Local was the only place content could go; with nil fallback there is no // external client to receive it at all. Local saw the content once. if local.calls != 1 { t.Errorf("local calls = %d, want 1", local.calls) } if !strings.Contains(local.lastUser, "secret confidential transcript body") { t.Error("transcript content should have reached the local stack") } } // Scenario: A user without BYO never has content sent externally — even across // repeated summarizations. Asserted structurally: a nil fallback means no // external endpoint exists, so content cannot leave the local stack. func TestSummarize_NoBYO_ContentOnlyLocal(t *testing.T) { local := &fakeClient{reply: goodReply} s := New(Endpoint{Client: local, Provider: "local", Model: "iguana/deepseek-r1-14b"}, nil) if s.fallback != nil { t.Fatal("no BYO configured but fallback endpoint is non-nil") } for i := 0; i < 3; i++ { sum, err := s.Summarize(context.Background(), testVideo(), testTranscript()) if err != nil { t.Fatalf("Summarize: %v", err) } if sum.AIProvider != "local" || sum.FallbackUsed { t.Errorf("provider=%q fallbackUsed=%v, want local/false", sum.AIProvider, sum.FallbackUsed) } } if local.calls != 3 { t.Errorf("local calls = %d, want 3", local.calls) } } func TestSummarize_EmptyTranscriptIsError(t *testing.T) { local := &fakeClient{reply: goodReply} s := New(Endpoint{Client: local, Provider: "local", Model: "m"}, nil) none := domain.Transcript{VideoID: "vid-1", Source: domain.SourceNone} if _, err := s.Summarize(context.Background(), testVideo(), none); err == nil { t.Fatal("want error for transcript with no text") } if local.calls != 0 { t.Errorf("local called %d times for empty transcript; must not call the model", local.calls) } } // parse tolerates thinking-model wrapping (reasoning + code fences around JSON). func TestParse_ToleratesFencedThinkingOutput(t *testing.T) { raw := "let me reason...\n```json\n" + goodReply + "\n```" p, err := parse(raw) if err != nil { t.Fatalf("parse: %v", err) } if p.Summary != "A talk about Go." { t.Errorf("summary = %q", p.Summary) } } func TestParse_EmptySummaryRejected(t *testing.T) { if _, err := parse(`{"summary":" ","highlights":[]}`); err == nil { t.Fatal("want error for empty summary (thinking model returned no content)") } }