package app import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) type materializingNotariusRunner struct { cfg *config.NotariusConfig requests []notarius.RunRequest } func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunRequest) (notarius.RunResult, error) { r.requests = append(r.requests, req) externalRunID := fmt.Sprintf("notarius-run-%d", len(r.requests)) bundle := filepath.Join(req.OutputRoot, externalRunID) lanesDir := filepath.Join(bundle, "lanes") if err := os.MkdirAll(lanesDir, 0o755); err != nil { return notarius.RunResult{}, err } for path, content := range map[string]string{ filepath.Join(bundle, "index.json"): `{"manifest_file":"manifest.json"}`, filepath.Join(bundle, "manifest.json"): `{}`, filepath.Join(bundle, "rejected.json"): `{"rejected":[]}`, filepath.Join(bundle, "warnings.json"): `{"warnings":[]}`, filepath.Join(lanesDir, "npcs.json"): `{"npcs":[]}`, } { if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return notarius.RunResult{}, err } } output := r.cfg.Outputs["npc_registry"] return notarius.RunResult{ Receipt: notarius.Receipt{ SchemaVersion: notarius.ReceiptSchemaVersion, RunID: externalRunID, PipelineID: req.PipelineID, OutputDirectory: bundle, IndexFile: "index.json", NormalizedOutputCount: 1, ValidationStatus: "valid", }, BundleRoot: bundle, Index: notarius.Index{ Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"), WarningsPath: filepath.Join(bundle, "warnings.json"), Lanes: []notarius.LaneDescriptor{{ LaneID: output.LaneID, File: "lanes/npcs.json", Path: filepath.Join(lanesDir, "npcs.json"), MediaType: output.MediaType, SchemaID: output.SchemaID, SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey, }}, }, }, nil } func TestExtractLifecycleDisabledThenEnabled(t *testing.T) { cfg, env, runner := extractionLifecycleFixture(t, false) plan, err := BuildSingleStagePlan("extract") if err != nil { t.Fatalf("BuildSingleStagePlan(extract) error = %v", err) } first, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) if err != nil { t.Fatalf("disabled executeStages() error = %v", err) } if len(first.Executed) != 1 || len(first.Skipped) != 1 || first.Skipped[0] != "extract" || len(runner.requests) != 0 { t.Fatalf("disabled summary = %#v requests=%d", first, len(runner.requests)) } cfg.Pipeline.Notarius.Enabled = true second, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) if err != nil { t.Fatalf("enabled executeStages() error = %v", err) } if len(second.Executed) != 1 || len(second.Skipped) != 0 || len(runner.requests) != 1 { t.Fatalf("enabled summary = %#v requests=%d", second, len(runner.requests)) } loaded, err := (&manifest.LocalStore{}).Load(context.Background(), second.ManifestPath) if err != nil { t.Fatalf("Load() error = %v", err) } if loaded.Stages["extract"] == nil || loaded.Stages["extract"].Status != manifest.StatusSucceeded || len(loaded.Stages["extract"].Outputs) != 2 { t.Fatalf("extract record = %#v, want succeeded manifest-ready outputs", loaded.Stages["extract"]) } } func TestExtractLifecycleSkipsCurrentResumableResult(t *testing.T) { cfg, env, runner := extractionLifecycleFixture(t, true) plan, _ := BuildSingleStagePlan("extract") if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil { t.Fatalf("first executeStages() error = %v", err) } resumed, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) if err != nil { t.Fatalf("resume executeStages() error = %v", err) } if len(resumed.Executed) != 0 || len(resumed.Skipped) != 1 || resumed.Skipped[0] != "extract" || len(runner.requests) != 1 { t.Fatalf("resume summary = %#v requests=%d", resumed, len(runner.requests)) } } func TestExtractLifecycleResumesAndRerunsObsoleteResults(t *testing.T) { for _, test := range []struct { name string mutate func(*testing.T, *config.Config, *manifest.Manifest) }{ {name: "configuration changed", mutate: func(_ *testing.T, cfg *config.Config, _ *manifest.Manifest) { output := cfg.Pipeline.Notarius.Outputs["npc_registry"] output.SchemaVersion = "v2" cfg.Pipeline.Notarius.Outputs["npc_registry"] = output }}, {name: "payload missing", mutate: func(t *testing.T, _ *config.Config, m *manifest.Manifest) { if err := os.Remove(m.Stages["extract"].Outputs[0].LocalPath); err != nil { t.Fatalf("Remove() error = %v", err) } }}, {name: "payload tampered", mutate: func(t *testing.T, _ *config.Config, m *manifest.Manifest) { if err := os.WriteFile(m.Stages["extract"].Outputs[0].LocalPath, []byte(`{"npcs":["tampered"]}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } }}, {name: "record incompatible", mutate: func(_ *testing.T, _ *config.Config, m *manifest.Manifest) { m.Stages["extract"].Outputs[0].Contract.SchemaID = "incompatible" }}, } { t.Run(test.name, func(t *testing.T) { cfg, env, runner := extractionLifecycleFixture(t, true) plan, _ := BuildSingleStagePlan("extract") first, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) if err != nil { t.Fatalf("first executeStages() error = %v", err) } persisted, err := (&manifest.LocalStore{}).Load(context.Background(), first.ManifestPath) if err != nil { t.Fatalf("Load() error = %v", err) } test.mutate(t, cfg, persisted) if err := (&manifest.LocalStore{}).Save(context.Background(), first.ManifestPath, persisted); err != nil { t.Fatalf("Save(mutated) error = %v", err) } rerun, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) if err != nil { t.Fatalf("rerun executeStages() error = %v", err) } if len(rerun.Executed) != 1 || len(rerun.Skipped) != 0 || len(runner.requests) != 2 { t.Fatalf("rerun summary = %#v requests=%d", rerun, len(runner.requests)) } }) } } func TestExtractLifecycleUnsafeResumeErrorPreservesSuccess(t *testing.T) { cfg, env, runner := extractionLifecycleFixture(t, true) plan, _ := BuildSingleStagePlan("extract") first, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) if err != nil { t.Fatalf("first executeStages() error = %v", err) } store := &manifest.LocalStore{} persisted, err := store.Load(context.Background(), first.ManifestPath) if err != nil { t.Fatalf("Load() error = %v", err) } persisted.Stages["extract"].Outputs[0].LocalPath = filepath.Join(cfg.Pipeline.Workspace.Root, "outside.json") if err := store.Save(context.Background(), first.ManifestPath, persisted); err != nil { t.Fatalf("Save() error = %v", err) } before, _ := json.Marshal(persisted.Stages["extract"]) if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err == nil || !strings.Contains(err.Error(), "unsafe") { t.Fatalf("executeStages() error = %v, want unsafe resume failure", err) } afterManifest, err := store.Load(context.Background(), first.ManifestPath) if err != nil { t.Fatalf("Load(after) error = %v", err) } after, _ := json.Marshal(afterManifest.Stages["extract"]) if string(before) != string(after) || len(runner.requests) != 1 { t.Fatalf("successful extract record changed: before=%s after=%s requests=%d", before, after, len(runner.requests)) } } func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *stage.Env, *materializingNotariusRunner) { t.Helper() cfg := testConfig(t) root := cfg.Pipeline.Workspace.Root binary := filepath.Join(root, "notarius") configPath := filepath.Join(root, "notarius.yml") workingDirectory := filepath.Join(root, "notarius-work") if err := os.WriteFile(binary, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatalf("WriteFile(binary) error = %v", err) } if err := os.WriteFile(configPath, []byte("pipelines: {}\n"), 0o644); err != nil { t.Fatalf("WriteFile(config) error = %v", err) } if err := os.Mkdir(workingDirectory, 0o755); err != nil { t.Fatalf("Mkdir(working directory) error = %v", err) } cfg.Pipeline.Notarius = &config.NotariusConfig{ Enabled: enabled, Binary: binary, ConfigPath: configPath, PipelineID: "dnd-session", Timeout: "45m", WorkingDirectory: workingDirectory, Outputs: map[string]config.NotariusOutputConfig{ "npc_registry": { LaneID: "npc-registry", MediaType: "application/json", SchemaID: "notarius.dnd.npc_registry", SchemaVersion: "v1", ModuleKey: "dnd/npc-registry", }, }, } paths, err := artifacts.NewLocalStore(root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID) if err != nil { t.Fatalf("EnsureLayoutFor() error = %v", err) } inputPath := filepath.Join(paths.ArtifactsDir, "trimmed.from-manifest.json") if err := os.WriteFile(inputPath, []byte(`{"segments":[]}`), 0o644); err != nil { t.Fatalf("WriteFile(input) error = %v", err) } m := manifest.New(cfg.Session.SessionID, time.Now().UTC()) m.Campaign = cfg.Session.Campaign m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{ Kind: artifactmodel.TranscriptOutputKindFinalTrimmed, SourceID: artifactmodel.SourceTranscriptFinalTrimmed, LocalPath: inputPath, }}) if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil { t.Fatalf("Save(seed) error = %v", err) } runner := &materializingNotariusRunner{cfg: cfg.Pipeline.Notarius} return cfg, &stage.Env{Notarius: runner}, runner }