package manifest import ( "context" "encoding/json" "path/filepath" "strings" "testing" "time" ) func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t *testing.T) { now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) store := &LocalStore{} zeroAudio := validTranscribeSpeakerRecord("Alice Table 1") zeroAudio.AudioSize = 0 for _, test := range []struct { name string records map[string]TranscribeSpeakerRecord }{ { name: "populated", records: map[string]TranscribeSpeakerRecord{ "Alice Table 1": validTranscribeSpeakerRecord("Alice Table 1"), }, }, {name: "versioned empty", records: map[string]TranscribeSpeakerRecord{}}, {name: "zero size audio", records: map[string]TranscribeSpeakerRecord{"Alice Table 1": zeroAudio}}, } { t.Run(test.name, func(t *testing.T) { m := New("session", now) m.Stages["transcribe"] = &StageRecord{ Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, TranscribeStateVersion: TranscribeStateContractVersion, TranscribeSpeakers: test.records, } path := filepath.Join(t.TempDir(), "manifest.json") if err := store.Save(context.Background(), path, m); err != nil { t.Fatalf("Save() error = %v", err) } loaded, err := store.Load(context.Background(), path) if err != nil { t.Fatalf("Load() error = %v", err) } stage := loaded.Stages["transcribe"] if !stage.HasVersionedTranscribeState() { t.Fatalf("transcribe state = %#v, want supported version", stage) } if len(stage.TranscribeSpeakers) != len(test.records) { t.Fatalf("speaker count = %d, want %d", len(stage.TranscribeSpeakers), len(test.records)) } if len(test.records) != 0 { got := stage.TranscribeSpeakers["Alice Table 1"] if got.AudioSize != test.records["Alice Table 1"].AudioSize { t.Fatalf("audio size = %d, want %d", got.AudioSize, test.records["Alice Table 1"].AudioSize) } if got.AudioPath != "inputs/audio/Alice Table 1.flac" || got.Output.LocalPath != "transcripts/raw/Alice Table 1.json" { t.Fatalf("round-tripped record = %#v", got) } } }) } } func TestTranscribeSpeakerStateAllowsLegacyAggregateOnlyManifest(t *testing.T) { legacy := `{ "schema_version": "narratio.session-manifest.v1", "session_id": "session", "created_at": "2026-09-21T12:00:00Z", "updated_at": "2026-09-21T12:01:00Z", "stages": { "transcribe": { "name": "transcribe", "status": "succeeded", "created_at": "2026-09-21T12:00:00Z", "updated_at": "2026-09-21T12:01:00Z", "outputs": [{"kind":"transcript_raw","local_path":"transcripts/raw/alice.json"}] } } }` loaded, err := (&LocalStore{}).LoadReader(context.Background(), strings.NewReader(legacy)) if err != nil { t.Fatalf("LoadReader() error = %v", err) } if loaded.Stages["transcribe"].HasVersionedTranscribeState() { t.Fatal("legacy aggregate-only manifest unexpectedly gained per-speaker authority") } if err := (&LocalStore{}).Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), loaded); err != nil { t.Fatalf("Save(legacy aggregate-only manifest) error = %v", err) } } func TestValidateTranscribeSpeakerCollectionRejectsMalformedEvidence(t *testing.T) { valid := validTranscribeSpeakerRecord("alice") tests := []struct { name string speakerID string mutate func(*TranscribeSpeakerRecord) want string }{ {name: "empty speaker ID", speakerID: "", want: "speaker ID is required"}, {name: "unsupported fingerprint version", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.FingerprintVersion = 2 }, want: "unsupported fingerprint version"}, {name: "bad fingerprint", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Fingerprint = "bad" }, want: "fingerprint must be"}, {name: "unsafe audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "../alice.flac" }, want: "audio_path is unsafe"}, {name: "noncanonical audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "inputs/audio/../alice.flac" }, want: "not canonical"}, {name: "bad audio checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSHA256 = strings.Repeat("G", 64) }, want: "audio_sha256 must be"}, {name: "negative audio size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSize = -1 }, want: "audio_size"}, {name: "bad output kind", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Kind = "transcript" }, want: "must be transcript_raw"}, {name: "unsafe output path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.LocalPath = "/tmp/alice.json" }, want: "output local_path is unsafe"}, {name: "bad output checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Checksum = "bad" }, want: "output checksum must be"}, {name: "bad output size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.OutputSize = 0 }, want: "output_size must be positive"}, {name: "bad producer run", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.ProducerRunID = "bad/run" }, want: "producer_run_id is invalid"}, {name: "producer mismatch", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.ProducerRunID = "other-run" }, want: "does not match record"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { record := valid if test.mutate != nil { test.mutate(&record) } err := ValidateTranscribeSpeakerCollection(TranscribeStateContractVersion, map[string]TranscribeSpeakerRecord{test.speakerID: record}) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want %q", err, test.want) } }) } if err := ValidateTranscribeSpeakerCollection(99, nil); err == nil || !strings.Contains(err.Error(), "unsupported transcribe state version") { t.Fatalf("unsupported version error = %v", err) } } func TestTranscribeSpeakerStateValidationAppliesOnSaveAndLoad(t *testing.T) { now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) invalid := validTranscribeSpeakerRecord("alice") invalid.AudioSHA256 = "bad" m := New("session", now) m.Stages["transcribe"] = &StageRecord{ Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, TranscribeStateVersion: TranscribeStateContractVersion, TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": invalid}, } store := &LocalStore{} if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), m); err == nil || !strings.Contains(err.Error(), "audio_sha256") { t.Fatalf("Save() error = %v, want audio evidence validation", err) } data, err := json.Marshal(m) if err != nil { t.Fatalf("Marshal() error = %v", err) } if _, err := store.LoadReader(context.Background(), strings.NewReader(string(data))); err == nil || !strings.Contains(err.Error(), "audio_sha256") { t.Fatalf("LoadReader() error = %v, want audio evidence validation", err) } } func TestTranscribeSpeakerStateOwnershipAndVersionAreValidated(t *testing.T) { now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) store := &LocalStore{} for _, test := range []struct { name string stage *StageRecord want string }{ { name: "wrong owner", stage: &StageRecord{Name: "merge", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, TranscribeStateVersion: TranscribeStateContractVersion, TranscribeSpeakers: map[string]TranscribeSpeakerRecord{}}, want: "cannot contain transcribe-owned state", }, { name: "missing version", stage: &StageRecord{Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")}}, want: "unsupported transcribe state version 0", }, { name: "unsupported version", stage: &StageRecord{Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, TranscribeStateVersion: 2}, want: "unsupported transcribe state version 2", }, } { t.Run(test.name, func(t *testing.T) { m := New("session", now) m.Stages[test.stage.Name] = test.stage if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), m); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Save() error = %v, want %q", err, test.want) } }) } } func TestCloneTranscribeSpeakerCollectionIsIsolated(t *testing.T) { original := map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")} cloned := CloneTranscribeSpeakerCollection(original) record := cloned["alice"] record.AudioPath = "inputs/audio/replacement.flac" record.Output.Checksum = strings.Repeat("c", 64) cloned["alice"] = record if got := original["alice"]; got.AudioPath != "inputs/audio/alice.flac" || got.Output.Checksum != strings.Repeat("b", 64) { t.Fatalf("original changed through clone: %#v", got) } } func TestTranscribeSpeakerStateSurvivesAggregateLifecycleTransitions(t *testing.T) { now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) for _, transition := range []struct { name string apply func(*Manifest) }{ {name: "running", apply: func(m *Manifest) { m.MarkStageRunning("transcribe", now.Add(time.Minute)) }}, {name: "failed", apply: func(m *Manifest) { m.MarkStageFailed("transcribe", now.Add(time.Minute), "request failed") }}, {name: "stale", apply: func(m *Manifest) { m.MarkStageStale("transcribe", now.Add(time.Minute), "audio changed") }}, } { t.Run(transition.name, func(t *testing.T) { m := New("session", now) m.Stages["transcribe"] = &StageRecord{ Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now, Outputs: []ArtifactRecord{{Kind: "transcript_raw", LocalPath: "transcripts/raw/old.json"}}, Logs: []string{"runs/old/transcribe.log"}, Metadata: map[string]any{"attempts": 1}, TranscribeStateVersion: TranscribeStateContractVersion, TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")}, } transition.apply(m) stage := m.Stages["transcribe"] if !stage.HasVersionedTranscribeState() || stage.TranscribeSpeakers["alice"].AudioPath != "inputs/audio/alice.flac" { t.Fatalf("speaker completion was lost during %s: %#v", transition.name, stage) } if transition.name != "stale" && (len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.Metadata) != 0) { t.Fatalf("aggregate details survived %s: %#v", transition.name, stage) } }) } } func validTranscribeSpeakerRecord(speakerID string) TranscribeSpeakerRecord { runID := "run-1" return TranscribeSpeakerRecord{ FingerprintVersion: TranscribeFingerprintContractVersion, Fingerprint: strings.Repeat("a", 64), AudioPath: "inputs/audio/" + speakerID + ".flac", AudioSHA256: strings.Repeat("1", 64), AudioSize: 1024, Output: ArtifactRecord{ Kind: "transcript_raw", LocalPath: "transcripts/raw/" + speakerID + ".json", Checksum: strings.Repeat("b", 64), ProducerRunID: runID, }, OutputSize: 256, ProducerRunID: runID, } }