package app import ( "bytes" "context" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/config" ) func TestConfigCommandHelpAndDispatch(t *testing.T) { for _, test := range []struct { name string args []string want string code int }{ {name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config "}, {name: "validate help", args: []string{"config", "validate", "--help"}, want: "Usage: narratio config validate"}, {name: "show help", args: []string{"config", "show", "--help"}, want: "Usage: narratio config show"}, {name: "unknown", args: []string{"config", "sources"}, want: `config: unknown subcommand "sources"`, code: 1}, {name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show", code: 1}, {name: "session flag", args: []string{"config", "validate", "--session", "session.yml"}, want: "flag provided but not defined", code: 1}, {name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1}, {name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1}, {name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1}, } { t.Run(test.name, func(t *testing.T) { var stdout, stderr bytes.Buffer code := Execute(test.args, &stdout, &stderr) if code != test.code { t.Fatalf("exit code = %d, want %d; stdout=%q stderr=%q", code, test.code, stdout.String(), stderr.String()) } combined := stdout.String() + stderr.String() if !strings.Contains(combined, test.want) { t.Fatalf("output = %q, want %q", combined, test.want) } }) } } func TestConfigValidateAndShowPipelineOnlyAreSideEffectFree(t *testing.T) { dir := t.TempDir() workspaceRoot := filepath.Join(dir, "workspace-does-not-exist") pipelinePath := filepath.Join(dir, "pipeline.yml") secretsDir := filepath.Join(dir, "secrets") const secret = "inspection-secret-must-not-escape" mustWriteTestFile(t, filepath.Join(secretsDir, "INSPECTION_SECRET"), secret+"\n") calls := 0 server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ })) defer server.Close() mustWriteTestFile(t, pipelinePath, `workspace: root: `+workspaceRoot+` secrets: env_dir: `+secretsDir+` whisperx: transcribe_url: `+server.URL+` `) t.Setenv("INSPECTION_SECRET", secret) var validateOut bytes.Buffer if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil { t.Fatalf("ConfigValidate() error = %v", err) } if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; profile=none; digest=") { t.Fatalf("validate output = %q", validateOut.String()) } var showOut bytes.Buffer if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &showOut); err != nil { t.Fatalf("ConfigShow() error = %v", err) } show := showOut.String() if !strings.HasSuffix(show, "\n") || !strings.Contains(show, "workspace:\n") || !strings.Contains(show, "root: "+workspaceRoot) { t.Fatalf("show output = %q", show) } if strings.Contains(show, "artifact_families") || strings.Contains(show, "resolution") { t.Fatalf("show output leaked resolution-only fields: %q", show) } if strings.Contains(show, secret) { t.Fatalf("show output leaked a raw secret: %q", show) } if calls != 0 { t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls) } if _, err := os.Stat(workspaceRoot); !os.IsNotExist(err) { t.Fatalf("workspace root stat error = %v, want absent", err) } } func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) enableCommandTestProfiles(t, pipelinePath) var defaultOut bytes.Buffer if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil { t.Fatalf("default ConfigValidate() error = %v", err) } if !strings.Contains(defaultOut.String(), "profile=production") { t.Fatalf("default output = %q", defaultOut.String()) } var explicitOut bytes.Buffer if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &explicitOut); err != nil { t.Fatalf("explicit ConfigShow() error = %v", err) } if !strings.Contains(explicitOut.String(), "language: fr") { t.Fatalf("explicit show output = %q", explicitOut.String()) } for _, args := range [][]string{ {"--config", pipelinePath, "--campaign", "sample-campaign"}, {"--config", pipelinePath, "--campaign-file", campaignPath}, } { var out bytes.Buffer if err := ConfigValidate(context.Background(), args, &out); err != nil { t.Fatalf("ConfigValidate(%q) error = %v", args, err) } } } func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) { pipelinePath, campaignPath := writeInspectionFamilyConfig(t) for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow} { var out bytes.Buffer err := command(context.Background(), []string{"--config", pipelinePath}, &out) if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") { t.Fatalf("family command without campaign error = %v", err) } } var show bytes.Buffer if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &show); err != nil { t.Fatalf("ConfigShow() error = %v", err) } if !strings.Contains(show.String(), "character_note_arannis:") || strings.Contains(show.String(), "artifact_families") { t.Fatalf("expanded show output = %q", show.String()) } loadedPipeline, err := config.LoadPipeline(pipelinePath) if err != nil { t.Fatal(err) } loadedCampaign, err := config.LoadCampaign(campaignPath) if err != nil { t.Fatal(err) } loaded, err := config.LoadPipelineCampaign(pipelinePath, loadedPipeline, campaignPath, loadedCampaign) if err != nil { t.Fatal(err) } var validateOut bytes.Buffer if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validateOut); err != nil { t.Fatal(err) } if !strings.Contains(validateOut.String(), "digest="+config.EffectivePipelineDigest(loaded.Pipeline)) { t.Fatalf("inspection digest = %q, want %q", validateOut.String(), config.EffectivePipelineDigest(loaded.Pipeline)) } } func TestConfigValidateReportsCanonicalPartyAndImportErrors(t *testing.T) { pipelinePath, campaignPath := writeInspectionFamilyConfig(t) mustWriteTestFile(t, filepath.Join(filepath.Dir(campaignPath), "party.yml"), "schema_version: narratio.party.v2\ncharacters: {}\n") if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, io.Discard); err == nil || !strings.Contains(err.Error(), "unsupported") { t.Fatalf("canonical party error = %v", err) } dir := t.TempDir() rootPath := filepath.Join(dir, "pipeline.yml") mustWriteTestFile(t, rootPath, `composition: imports: [conf.yml] workspace: root: /one whisperx: transcribe_url: https://transcription.example.com/transcribe `) mustWriteTestFile(t, filepath.Join(dir, "conf.yml"), "workspace:\n root: /two\n") err := ConfigValidate(context.Background(), []string{"--config", rootPath}, io.Discard) if err == nil || !strings.Contains(err.Error(), "workspace.root") || !strings.Contains(err.Error(), "pipeline.yml") || !strings.Contains(err.Error(), "conf.yml") { t.Fatalf("import diagnostic = %v", err) } } func writeInspectionFamilyConfig(t *testing.T) (string, string) { t.Helper() dir := t.TempDir() campaignRoot := filepath.Join(dir, "campaigns") campaignDir := filepath.Join(campaignRoot, "sample-campaign") pipelinePath := filepath.Join(dir, "pipeline.yml") campaignPath := filepath.Join(campaignDir, "campaign.yml") mustWriteTestFile(t, pipelinePath, `workspace: root: `+filepath.Join(dir, "workspace")+` campaigns: root: `+campaignRoot+` whisperx: transcribe_url: https://transcription.example.com/transcribe scriptorium: artifact_families: character_note: enabled: false for_each: party.characters output_path_pattern: artifacts/characters/{character_id}/note.md `) mustWriteTestFile(t, campaignPath, `campaign_id: sample-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml party_file: ./party.yml `) mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), `schema_version: narratio.party.v1 characters: arannis: player: {name: Eric} character: {name: Arannis, classes: [{name: wizard}]} `) return pipelinePath, campaignPath }