Files
mathiasandClaude Sonnet 4.6 38f222c931
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 12s
chore: rename Go module path gitea.d-ma.be → git.d-ma.be
Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and
all .go import paths. Build and tests pass unchanged.

Closes #20

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
2026-07-02 14:37:33 +02:00

296 lines
11 KiB
Go

// 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"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/metrics"
"git.d-ma.be/mathias/tapir/internal/ports"
)
// TestSummarizerRecordsMetric verifies the summarizer→metrics wiring (ADR-030)
// black-box: after a successful summarize, the public /metrics scrape shows a
// success observation for that endpoint's model.
func TestSummarizerRecordsMetric(t *testing.T) {
const model = "metrics-test-model"
s := New(Endpoint{Client: &fakeClient{reply: goodReply}, Provider: "local", Model: model}, nil)
if _, err := s.Summarize(context.Background(), testVideo(), testTranscript()); err != nil {
t.Fatalf("Summarize: %v", err)
}
rec := httptest.NewRecorder()
metrics.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := rec.Body.String()
if !strings.Contains(body, `tapir_summarize_duration_seconds`) ||
!strings.Contains(body, `model="`+model+`"`) ||
!strings.Contains(body, `outcome="success"`) {
t.Errorf("metrics scrape missing summarize success for %s", model)
}
}
// 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 len(s.endpoints) != 1 {
t.Fatalf("no BYO configured but chain has %d endpoints, want 1", len(s.endpoints))
}
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 := "<think>let me reason...</think>\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)")
}
}
// parse tolerates a small model emitting "highlights" as a bare string instead
// of an array — the production koala/phi4-mini quirk that errored with
// "cannot unmarshal string into Go struct field ... highlights of type []string".
func TestParse_ToleratesStringHighlights(t *testing.T) {
p, err := parse(`{"summary":"s","highlights":"one big point","takeaways":["a","b"]}`)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(p.Highlights) != 1 || p.Highlights[0] != "one big point" {
t.Errorf("highlights = %v, want [\"one big point\"]", p.Highlights)
}
if len(p.Takeaways) != 2 {
t.Errorf("takeaways = %v, want 2", p.Takeaways)
}
}
// Chain: an endpoint that returns a 200 with unparseable output is treated as a
// failure, and the next endpoint in the chain is tried. This is the case the old
// primary->fallback shape missed — a parse error short-circuited instead of
// falling back.
func TestSummarize_FallsBackOnMalformedOutput(t *testing.T) {
bad := &fakeClient{reply: `{"summary": not json`}
good := &fakeClient{reply: goodReply}
s := NewChain([]Endpoint{
{Client: bad, Provider: "local", Model: "koala/phi4-mini"},
{Client: good, Provider: "local", Model: "koala/phi4-14b"},
}, 0)
sum, err := s.Summarize(context.Background(), testVideo(), testTranscript())
if err != nil {
t.Fatalf("Summarize: %v", err)
}
if sum.AIModel != "koala/phi4-14b" {
t.Errorf("AIModel = %q, want koala/phi4-14b (fell back past malformed primary)", sum.AIModel)
}
if !sum.FallbackUsed {
t.Error("FallbackUsed = false, want true")
}
if bad.calls != 1 || good.calls != 1 {
t.Errorf("calls: bad=%d good=%d, want 1 and 1", bad.calls, good.calls)
}
}
// Chain: when every endpoint fails, no summary is produced and the joined error
// names each failure so the engine queues the work for retry.
func TestSummarize_ChainAllEndpointsFail(t *testing.T) {
a := &fakeClient{err: errors.New("context overflow")}
b := &fakeClient{reply: "not even json"}
s := NewChain([]Endpoint{
{Client: a, Provider: "local", Model: "m1"},
{Client: b, Provider: "berget", Model: "m2"},
}, 0)
if _, err := s.Summarize(context.Background(), testVideo(), testTranscript()); err == nil {
t.Fatal("want error when all endpoints fail")
}
if a.calls != 1 || b.calls != 1 {
t.Errorf("calls: a=%d b=%d, want 1 and 1", a.calls, b.calls)
}
}
// A transcript longer than the chain's input budget is truncated before it
// reaches any model, so a small-context primary does not overflow its window.
func TestSummarize_TruncatesLongTranscript(t *testing.T) {
local := &fakeClient{reply: goodReply}
const budget = 100
s := NewChain([]Endpoint{{Client: local, Provider: "local", Model: "m"}}, budget)
long := domain.Transcript{
VideoID: "vid-1", UserID: "user-1", Source: domain.SourceCaptions,
Content: strings.Repeat("word ", 1000), // 5000 bytes, well over budget
}
if _, err := s.Summarize(context.Background(), testVideo(), long); err != nil {
t.Fatalf("Summarize: %v", err)
}
// The prompt carries title/URL framing plus the truncation marker, so allow
// headroom over the raw transcript budget — but it must be far below 5000.
if len(local.lastUser) > budget+300 {
t.Errorf("prompt length = %d, want <= %d (transcript not truncated)", len(local.lastUser), budget+300)
}
if !strings.Contains(local.lastUser, "truncated") {
t.Error("truncation marker missing from prompt")
}
}
func TestNewChain_PanicsOnEmptyChain(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("want panic on empty endpoint chain")
}
}()
NewChain(nil, 0)
}