package state import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestFilesystemPathsUseExactPromptArtifactLayout(t *testing.T) { store := newFilesystemTestStore(t) tests := []struct { id report.ID now string date string group string validDate string runID string }{ {report.Daily, "2026-05-29T05:00:00-05:00", "2026-05-29T12:00:00-05:00", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29"}, {report.Today, "2026-05-29T05:00:00-05:00", "", "today", "2026-05-29", "20260529T100000.000000000Z_today"}, {report.Tomorrow, "2026-05-29T18:00:00-05:00", "", "tomorrow", "2026-05-30", "20260529T230000.000000000Z_tomorrow"}, {report.Hourly, "2026-05-29T05:00:00-05:00", "", "hourly", "2026-05-29", "20260529T100000.000000000Z_hourly"}, } for _, test := range tests { t.Run(string(test.id), func(t *testing.T) { resolved := resolveStateReport(t, test.id, test.now, test.date) paths, err := store.Paths(resolved) if err != nil { t.Fatalf("Paths() error = %v", err) } want := ArtifactPaths{ ModuleSnapshot: filepath.Join(store.root, "snapshots", test.group, test.validDate, "modules."+test.runID+".json"), Metadata: filepath.Join(store.root, "snapshots", test.group, test.validDate, "metadata."+test.runID+".json"), DataPackage: filepath.Join(store.root, "data-packages", test.group, test.validDate, "data_package."+test.runID+".yaml"), Preparation: filepath.Join(store.root, "preflight", test.group, test.validDate, "prompt_preparation."+test.runID+".json"), Execution: filepath.Join(store.root, "snapshots", test.group, test.validDate, "prompt_execution."+test.runID+".json"), Notification: filepath.Join(store.root, "notifications", test.group, test.validDate, "distributor."+test.runID+".json"), RenderedReport: filepath.Join(store.root, "reports", test.group, test.validDate, "report."+test.runID+".md"), GeneratedTextRaw: filepath.Join(store.root, "snapshots", test.group, test.validDate, "generated_text_raw."+test.runID+".json"), GeneratedText: filepath.Join(store.root, "snapshots", test.group, test.validDate, "generated_text."+test.runID+".json"), RenderContext: filepath.Join(store.root, "snapshots", test.group, test.validDate, "render_context."+test.runID+".json"), } if paths != want { t.Fatalf("Paths() = %#v, want %#v", paths, want) } }) } } func TestPromptArtifactsAndMetadataRoundTrip(t *testing.T) { store := newFilesystemTestStore(t) resolved := resolveStateReport(t, report.Daily, "2026-05-29T05:00:00-05:00", "2026-05-29T12:00:00-05:00") paths, err := store.Paths(resolved) if err != nil { t.Fatalf("Paths() error = %v", err) } preparation := preparationArtifactFor(resolved, paths) preparationPath, err := store.SavePromptPreparation(context.Background(), resolved, preparation) if err != nil { t.Fatalf("SavePromptPreparation() error = %v", err) } execution := executionArtifactFor(resolved, paths) executionPath, err := store.SavePromptExecution(context.Background(), resolved, execution) if err != nil { t.Fatalf("SavePromptExecution() error = %v", err) } loadedPreparation, err := store.LoadPromptPreparation(context.Background(), preparationPath) if err != nil || loadedPreparation.Preparation == nil || loadedPreparation.Preparation.PromptHash != "prompt-hash" { t.Fatalf("LoadPromptPreparation() = %#v, %v", loadedPreparation, err) } loadedExecution, err := store.LoadPromptExecution(context.Background(), executionPath) if err != nil || loadedExecution.Provenance == nil || loadedExecution.Provenance.RunID != "provider-run" || loadedExecution.Validation == nil { t.Fatalf("LoadPromptExecution() = %#v, %v", loadedExecution, err) } metadata := promptMetadataFor(resolved, paths) metadata.PreparationPath = preparationPath metadata.ExecutionPath = executionPath metadata.GeneratedTextRawPath = paths.GeneratedTextRaw metadataPath, err := store.SaveMetadata(context.Background(), metadata) if err != nil { t.Fatalf("SaveMetadata() error = %v", err) } data, err := os.ReadFile(metadataPath) if err != nil { t.Fatalf("read metadata: %v", err) } text := string(data) if strings.Contains(text, "preflightPath") || strings.Contains(text, "generatedTextResultPath") || strings.Contains(text, "metadataPath") { t.Fatalf("v2 metadata contains legacy or runtime aliases: %s", text) } loadedMetadata, loadedPath, err := store.LoadMetadataByRunID(context.Background(), metadata.RunID) if err != nil || loadedPath != metadataPath || loadedMetadata.PreparationPath != preparationPath || loadedMetadata.ExecutionPath != executionPath { t.Fatalf("LoadMetadataByRunID() = %#v, %q, %v", loadedMetadata, loadedPath, err) } } func TestMetadataLegacyCompatibilityAndV2OnlyWrites(t *testing.T) { legacy := Metadata{ SchemaVersion: MetadataSchemaVersionV1, RunID: "legacy-run", ReportID: report.ID("three_day"), PromptID: "weather.three_day", ModuleSnapshotPath: "/archive/modules.json", DataPackagePath: "/archive/data.yaml", PreflightPath: "/archive/render.json", GeneratedTextResultPath: "/archive/result.json", } data, err := json.Marshal(legacy) if err != nil { t.Fatalf("Marshal() error = %v", err) } text := string(data) if !strings.Contains(text, "preflightPath") || !strings.Contains(text, "generatedTextResultPath") || strings.Contains(text, "preparationPath") || strings.Contains(text, "executionPath") { t.Fatalf("v1 metadata wire fields = %s", text) } var decoded Metadata if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if decoded.PreparationPath != legacy.PreflightPath || decoded.ExecutionPath != legacy.GeneratedTextResultPath { t.Fatalf("normalized compatibility aliases = %#v", decoded) } remarshaled, err := json.Marshal(decoded) if err != nil || !strings.Contains(string(remarshaled), "preflightPath") || strings.Contains(string(remarshaled), "preparationPath") { t.Fatalf("remarshaled v1 metadata = %s, %v", remarshaled, err) } if err := json.Unmarshal([]byte(`{"schemaVersion":"weatherreporter.metadata.v99"}`), &decoded); err == nil { t.Fatal("Unmarshal() error = nil, want unknown schema rejection") } store := newFilesystemTestStore(t) legacy.MetadataPath = filepath.Join(store.root, "snapshots", "legacy", "metadata.legacy-run.json") if _, err := store.SaveMetadata(context.Background(), legacy); err == nil { t.Fatal("SaveMetadata(v1) error = nil, want v2-only write rejection") } } func TestReportDiscoveryAndArtifactInspection(t *testing.T) { store := newFilesystemTestStore(t) older := resolveStateReport(t, report.Daily, "2026-05-29T05:00:00-05:00", "2026-05-29T12:00:00-05:00") newer := resolveStateReport(t, report.Today, "2026-05-29T08:00:00-05:00", "") olderPaths := saveStateMetadata(t, store, older) newerPaths := saveStateMetadata(t, store, newer) snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]any{"run_id": older.Metadata().RunID}}}) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } modulePath, err := store.SaveModuleSnapshot(context.Background(), older, snapshot) if err != nil { t.Fatalf("SaveModuleSnapshot() error = %v", err) } pkg, err := promptinput.Build(promptinput.BuildRequest{ Metadata: promptinput.Metadata{ RunID: older.Metadata().RunID, ReportID: older.Definition.ID, PromptID: older.Definition.PromptID, GeneratedAt: older.GeneratedAt, Timezone: older.Timezone, ValidPeriod: older.ValidPeriod, }, Modules: snapshot, }) if err != nil { t.Fatalf("Build() error = %v", err) } dataPath, err := store.SaveDataPackage(context.Background(), older, pkg) if err != nil { t.Fatalf("SaveDataPackage() error = %v", err) } loadedSnapshot, err := store.LoadModuleSnapshot(context.Background(), modulePath) if err != nil || len(loadedSnapshot.Outputs) != 1 { t.Fatalf("LoadModuleSnapshot() = %#v, %v", loadedSnapshot, err) } loadedPackage, err := store.LoadDataPackage(context.Background(), dataPath) if err != nil || loadedPackage.RunID != older.Metadata().RunID { t.Fatalf("LoadDataPackage() = %#v, %v", loadedPackage, err) } records, err := store.ListReports(context.Background(), 0) if err != nil || len(records) != 2 { t.Fatalf("ListReports() = %#v, %v", records, err) } if records[0].RunID != newer.Metadata().RunID || records[0].MetadataPath != newerPaths.Metadata || records[1].MetadataPath != olderPaths.Metadata { t.Fatalf("ordered report records = %#v", records) } loadedMetadata, loadedPath, err := store.LoadMetadataByRunID(context.Background(), older.Metadata().RunID) if err != nil || loadedPath != olderPaths.Metadata || len(loadedMetadata.Sources) != 1 || loadedMetadata.Sources[0].Name != "weather-api" { t.Fatalf("LoadMetadataByRunID() = %#v, %q, %v", loadedMetadata, loadedPath, err) } } func TestListReportsRetainsHistoricalV1ReportIDs(t *testing.T) { store := newFilesystemTestStore(t) for i, id := range []report.ID{"three_day", "weekend", "storm"} { generatedAt := time.Date(2026, 5, 20+i, 12, 0, 0, 0, time.UTC) metadata := Metadata{ SchemaVersion: MetadataSchemaVersionV1, RunID: "historical-" + string(id), ReportID: id, PromptID: "weather." + string(id), GeneratedAt: generatedAt, Timezone: "UTC", ValidPeriod: timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)}, ModuleSnapshotPath: "/archive/modules.json", DataPackagePath: "/archive/data.yaml", PreflightPath: "/archive/render.json", GeneratedTextResultPath: "/archive/result.json", } path := filepath.Join(store.root, "snapshots", string(id), "2026-05-20", "metadata."+metadata.RunID+".json") writeJSONFixture(t, path, metadata) } records, err := store.ListReports(context.Background(), 0) if err != nil || len(records) != 3 { t.Fatalf("ListReports() = %#v, %v", records, err) } for _, record := range records { if record.ReportID != "three_day" && record.ReportID != "weekend" && record.ReportID != "storm" { t.Fatalf("unexpected historical report record: %#v", record) } metadata, path, err := store.LoadMetadataByRunID(context.Background(), record.RunID) if err != nil || path != record.MetadataPath || metadata.PreparationPath != "/archive/render.json" || metadata.ExecutionPath != "/archive/result.json" { t.Fatalf("LoadMetadataByRunID(%q) = %#v, %q, %v", record.RunID, metadata, path, err) } } } func TestFilesystemWritesAreAtomicAndRejectUnsafePaths(t *testing.T) { store := newFilesystemTestStore(t) resolved := resolveStateReport(t, report.Hourly, "2026-05-29T05:00:00-05:00", "") path, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"value":"first"}`)) if err != nil { t.Fatalf("first SaveGeneratedTextRaw() error = %v", err) } if _, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"value":"second"}`)); err != nil { t.Fatalf("second SaveGeneratedTextRaw() error = %v", err) } data, err := os.ReadFile(path) if err != nil || string(data) != `{"value":"second"}` { t.Fatalf("atomic replacement = %q, %v", data, err) } entries, err := os.ReadDir(filepath.Dir(path)) if err != nil || len(entries) != 1 || entries[0].Name() != filepath.Base(path) { t.Fatalf("artifact directory after atomic write = %#v, %v", entries, err) } metadata := promptMetadataFor(resolved, mustStatePaths(t, store, resolved)) metadata.PreparationPath = "/saved/preparation.json" metadata.MetadataPath = filepath.Join(t.TempDir(), "outside.json") if _, err := store.SaveMetadata(context.Background(), metadata); err == nil { t.Fatal("SaveMetadata(outside workspace) error = nil") } cfg := config.Defaults().Workspace cfg.Root = t.TempDir() cfg.SnapshotsDir = "../snapshots" if _, err := NewFilesystemStore(cfg); err == nil { t.Fatal("NewFilesystemStore(unsafe directory) error = nil") } unsafeResolved := resolved unsafeResolved.Definition.ID = report.ID("hourly/bad") if _, err := store.Paths(unsafeResolved); err == nil { t.Fatal("Paths(unsafe run id) error = nil") } } func TestFindPriorSnapshotForSupportedReports(t *testing.T) { tests := []struct { id report.ID firstNow string secondNow string date string wantPrior bool }{ {report.Daily, "2026-05-29T05:00:00-05:00", "2026-05-29T08:00:00-05:00", "2026-05-29T12:00:00-05:00", true}, {report.Today, "2026-05-29T05:00:00-05:00", "2026-05-29T08:00:00-05:00", "", true}, {report.Tomorrow, "2026-05-29T17:00:00-05:00", "2026-05-29T18:00:00-05:00", "", true}, {report.Hourly, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "", false}, } for _, test := range tests { t.Run(string(test.id), func(t *testing.T) { store := newFilesystemTestStore(t) first := resolveStateReport(t, test.id, test.firstNow, test.date) second := resolveStateReport(t, test.id, test.secondNow, test.date) paths := saveStateMetadata(t, store, first) prior, err := store.FindPriorSnapshot(context.Background(), second) if err != nil { t.Fatalf("FindPriorSnapshot() error = %v", err) } if !test.wantPrior && prior != nil { t.Fatalf("FindPriorSnapshot() = %#v, want nil", prior) } if test.wantPrior && (prior == nil || prior.Metadata.RunID != first.Metadata().RunID || prior.ModuleSnapshotPath != paths.ModuleSnapshot) { t.Fatalf("FindPriorSnapshot() = %#v, want run %q", prior, first.Metadata().RunID) } }) } } func newFilesystemTestStore(t *testing.T) *FilesystemStore { t.Helper() cfg := config.Defaults().Workspace cfg.Root = t.TempDir() store, err := NewFilesystemStore(cfg) if err != nil { t.Fatalf("NewFilesystemStore() error = %v", err) } return store } func resolveStateReport(t *testing.T, id report.ID, nowValue, dateValue string) report.Resolved { t.Helper() location, err := time.LoadLocation("America/Chicago") if err != nil { t.Fatalf("LoadLocation() error = %v", err) } now, err := time.Parse(time.RFC3339, nowValue) if err != nil { t.Fatalf("parse now: %v", err) } req := report.ResolveRequest{Now: now, Location: location} if dateValue != "" { req.Date, err = time.Parse(time.RFC3339, dateValue) if err != nil { t.Fatalf("parse date: %v", err) } } resolved, err := report.DefaultRegistry().Resolve(id, req) if err != nil { t.Fatalf("Resolve() error = %v", err) } return resolved } func mustStatePaths(t *testing.T, store *FilesystemStore, resolved report.Resolved) ArtifactPaths { t.Helper() paths, err := store.Paths(resolved) if err != nil { t.Fatalf("Paths() error = %v", err) } return paths } func promptMetadataFor(resolved report.Resolved, paths ArtifactPaths) Metadata { metadata := BuildPromptMetadataFromBriefingMetadata(resolved, briefing.Metadata{ Sources: []briefing.SourceMetadata{{Name: "weather-api", FetchedAt: resolved.GeneratedAt}}, SourceWarnings: []weatherdata.SourceWarning{}, }, ArtifactPaths{ModuleSnapshot: paths.ModuleSnapshot, Metadata: paths.Metadata, DataPackage: paths.DataPackage}) return metadata } func saveStateMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved) ArtifactPaths { t.Helper() paths := mustStatePaths(t, store, resolved) metadata := promptMetadataFor(resolved, paths) metadata.PreparationPath = paths.Preparation if _, err := store.SaveMetadata(context.Background(), metadata); err != nil { t.Fatalf("SaveMetadata() error = %v", err) } return paths } func preparationArtifactFor(resolved report.Resolved, paths ArtifactPaths) PromptPreparationArtifact { artifact := validPreparationArtifact() metadata := resolved.Metadata() artifact.ReportID, artifact.RunID = metadata.ReportID, metadata.RunID artifact.PromptID, artifact.PromptVersion = resolved.Definition.PromptID, resolved.Definition.PromptVersion artifact.DataPackagePath = paths.DataPackage artifact.Preparation.PromptID, artifact.Preparation.PromptVersion = artifact.PromptID, artifact.PromptVersion artifact.Preparation.PromptHash = "prompt-hash" artifact.Preparation.DataPackagePath = paths.DataPackage return artifact } func executionArtifactFor(resolved report.Resolved, paths ArtifactPaths) PromptExecutionArtifact { artifact := validExecutionArtifact() metadata := resolved.Metadata() artifact.ReportID, artifact.RunID = metadata.ReportID, metadata.RunID artifact.PromptID, artifact.PromptVersion = resolved.Definition.PromptID, resolved.Definition.PromptVersion artifact.Provenance.PromptID, artifact.Provenance.PromptVersion = artifact.PromptID, artifact.PromptVersion artifact.Provenance.DataPackagePath = paths.DataPackage artifact.Paths.RawOutputPath = paths.GeneratedTextRaw return artifact } func writeJSONFixture(t *testing.T, path string, value any) { t.Helper() data, err := json.Marshal(value) if err != nil { t.Fatalf("marshal fixture: %v", err) } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("create fixture directory: %v", err) } if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatalf("write fixture: %v", err) } }