package config import ( "os" "path/filepath" "strings" "testing" ) func TestPipelineCampaignRegistryStrictDecode(t *testing.T) { dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") pipelineYAML := `workspace: root: /tmp/narratio-work campaigns: root: /srv/narratio/campaigns default_campaign_id: dilfs whisperx: transcribe_url: https://example.com/transcribe notification: mode: noop ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) } cfg, err := LoadPipeline(pipelinePath) if err != nil { t.Fatalf("LoadPipeline() error = %v", err) } if cfg.Campaigns.Root != "/srv/narratio/campaigns" { t.Fatalf("campaigns.root = %q", cfg.Campaigns.Root) } if cfg.Campaigns.DefaultCampaignID != "dilfs" { t.Fatalf("campaigns.default_campaign_id = %q", cfg.Campaigns.DefaultCampaignID) } } func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err != nil { t.Fatalf("LoadWithSessionOptions() error = %v", err) } if CampaignID(cfg.Campaign) != "sample-campaign" { t.Fatalf("CampaignID() = %q, want sample-campaign", CampaignID(cfg.Campaign)) } } func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err == nil { t.Fatal("expected load error, got nil") } if !strings.Contains(err.Error(), "campaign file") || !strings.Contains(err.Error(), "strict decode failed") { t.Fatalf("error = %q, want campaign strict decode context", err.Error()) } } func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err == nil { t.Fatal("expected load error, got nil") } if !strings.Contains(err.Error(), "campaign file") || !strings.Contains(err.Error(), "strict decode failed") { t.Fatalf("error = %q, want campaign strict decode context", err.Error()) } } func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err != nil { t.Fatalf("LoadWithSessionOptions() error = %v", err) } if cfg.Campaign.SessionTemplateFile != "./session.template.yml" { t.Fatalf("SessionTemplateFile = %q, want ./session.template.yml", cfg.Campaign.SessionTemplateFile) } } func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err != nil { t.Fatalf("LoadWithSessionOptions() error = %v", err) } if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } if cfg.Session.Campaign != "sample-campaign" { t.Fatalf("session campaign = %q, want campaign config value", cfg.Session.Campaign) } assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./campaign-players.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config") } func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n", "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n players_file: ./session-players.yml\n party_file: ./session-party.yml\n", ) cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err != nil { t.Fatalf("LoadWithSessionOptions() error = %v", err) } if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config") assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./session-players.yml", sessionPath, "session_config") assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config") } func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err != nil { t.Fatalf("LoadWithSessionOptions() error = %v", err) } err = Validate(cfg) if err == nil { t.Fatal("expected validation error, got nil") } if !strings.Contains(err.Error(), "campaign.inputs.players_file is required") { t.Fatalf("error = %q, want players_file required", err.Error()) } } func TestCampaignSessionMismatchFails(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n", ) _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) if err == nil { t.Fatal("expected load error, got nil") } if !strings.Contains(err.Error(), "does not match campaign config") { t.Fatalf("error = %q, want campaign mismatch context", err.Error()) } } func TestLoadMissingCampaignFileFails(t *testing.T) { pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", ) missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml") _, err := LoadWithSessionOptions(pipelinePath, missingCampaignPath, sessionPath, SessionLoadOptions{}) if err == nil { t.Fatal("expected load error, got nil") } if !strings.Contains(err.Error(), "load campaign config") { t.Fatalf("error = %q, want campaign load context", err.Error()) } } func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string) (string, string, string) { t.Helper() dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") campaignPath := filepath.Join(dir, "campaign.yml") sessionPath := filepath.Join(dir, "session.yml") pipelineYAML := "workspace:\n root: " + filepath.ToSlash(filepath.Join(dir, "work")) + "\nwhisperx:\n transcribe_url: https://example.com/transcribe\nnotification:\n mode: noop\n" if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) } if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign.yml: %v", err) } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } return pipelinePath, campaignPath, sessionPath } func assertResolvedStableInput(t *testing.T, got ResolvedInputFile, wantPath, wantConfigPath, wantSource string) { t.Helper() if got.Path != wantPath || got.ConfigPath != wantConfigPath || got.Source != wantSource { t.Fatalf("resolved input = %#v, want path=%q config_path=%q source=%q", got, wantPath, wantConfigPath, wantSource) } }