976 lines
34 KiB
Go
976 lines
34 KiB
Go
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"
|
|
)
|
|
|
|
func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
|
store := newTestStore(t)
|
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
|
|
|
paths, err := store.Paths(resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
|
|
for _, want := range []string{
|
|
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.modules.json"),
|
|
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.metadata.json"),
|
|
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.data_package.yaml"),
|
|
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.render.json"),
|
|
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.distributor.json"),
|
|
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_2026-05-29.md"),
|
|
} {
|
|
if !strings.Contains(pathsString(paths), want) {
|
|
t.Fatalf("paths = %#v, want component %q", paths, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) {
|
|
store := newTestStore(t)
|
|
first := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-05-31T12:00:00-05:00")
|
|
second := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-06-01T12:00:00-05:00")
|
|
|
|
firstPaths, err := store.Paths(first)
|
|
if err != nil {
|
|
t.Fatalf("Paths(first) error = %v", err)
|
|
}
|
|
secondPaths, err := store.Paths(second)
|
|
if err != nil {
|
|
t.Fatalf("Paths(second) error = %v", err)
|
|
}
|
|
|
|
if first.Metadata().RunID == second.Metadata().RunID {
|
|
t.Fatalf("RunIDs both = %q, want distinct daily run ids", first.Metadata().RunID)
|
|
}
|
|
for name, values := range map[string][2]string{
|
|
"RenderedReport": {firstPaths.RenderedReport, secondPaths.RenderedReport},
|
|
"Metadata": {firstPaths.Metadata, secondPaths.Metadata},
|
|
"DataPackage": {firstPaths.DataPackage, secondPaths.DataPackage},
|
|
"Notification": {firstPaths.Notification, secondPaths.Notification},
|
|
} {
|
|
if values[0] == values[1] {
|
|
t.Fatalf("%s paths both = %q, want distinct daily artifact paths", name, values[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testing.T) {
|
|
store := newTestStore(t)
|
|
location := mustLoadStateLocation(t, "America/Chicago")
|
|
startedAt := time.Date(2026, 6, 18, 3, 30, 0, 123456789, time.UTC)
|
|
|
|
path, err := store.BatchDistributorNotificationPath(BatchDistributorNotificationRef{
|
|
Batch: "evening",
|
|
BatchRunID: "20260618T033000.123456789Z_evening",
|
|
StartedAt: startedAt,
|
|
Location: location,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BatchDistributorNotificationPath() error = %v", err)
|
|
}
|
|
want := filepath.Join("notifications", "batches", "evening", "2026-06-17", "20260618T033000.123456789Z_evening.distributor.json")
|
|
if !strings.Contains(path, want) {
|
|
t.Fatalf("path = %q, want component %q", path, want)
|
|
}
|
|
if !strings.HasPrefix(path, store.root) {
|
|
t.Fatalf("path = %q, want workspace root prefix %q", path, store.root)
|
|
}
|
|
}
|
|
|
|
func TestSaveBatchDistributorNotificationRoundTrip(t *testing.T) {
|
|
store := newTestStore(t)
|
|
location := mustLoadStateLocation(t, "America/Chicago")
|
|
startedAt := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
|
|
bundleCreated := startedAt.Add(2 * time.Second)
|
|
attemptedAt := startedAt.Add(3 * time.Second)
|
|
acceptedAt := startedAt.Add(4 * time.Second)
|
|
finishedAt := startedAt.Add(5 * time.Second)
|
|
ref := BatchDistributorNotificationRef{
|
|
Batch: "morning",
|
|
BatchRunID: "20260617T120000.000000000Z_morning",
|
|
StartedAt: startedAt,
|
|
Location: location,
|
|
}
|
|
|
|
path, err := store.SaveBatchDistributorNotification(context.Background(), ref, BatchDistributorNotificationArtifact{
|
|
AttemptedAt: attemptedAt,
|
|
Endpoint: "https://distributor.example.test",
|
|
PipelineID: "weatherreporter",
|
|
BundleID: "weatherreporter.home.morning",
|
|
IdempotencyKey: "weatherreporter.home.morning.20260617T120000.000000000Z_morning",
|
|
BundleCreated: bundleCreated,
|
|
Reports: []BatchDistributorNotificationReportArtifact{
|
|
{
|
|
ReportID: report.Today,
|
|
RunID: "20260617T120000.000000000Z_today",
|
|
SourcePath: "/workspace/reports/today/20260617T120000.000000000Z_today.md",
|
|
BundlePaths: []string{"2026-06-17/today/report.md"},
|
|
},
|
|
{
|
|
ReportID: report.Daily,
|
|
RunID: "20260617T120000.000000000Z_daily_2026-06-19",
|
|
SourcePath: "/workspace/reports/daily/20260617T120000.000000000Z_daily_2026-06-19.md",
|
|
BundlePaths: []string{"2026-06-19/daily/report.md"},
|
|
},
|
|
},
|
|
Status: "failed",
|
|
Upload: &DistributorUploadResult{
|
|
RunID: "distributor-run",
|
|
Status: "accepted",
|
|
},
|
|
RunStatus: &DistributorRunStatus{
|
|
RunID: "distributor-run",
|
|
PipelineID: "weatherreporter",
|
|
Status: "failed",
|
|
AcceptedAt: acceptedAt,
|
|
FinishedAt: &finishedAt,
|
|
Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`),
|
|
Error: "destination conflict",
|
|
},
|
|
StatusError: "status lookup failed",
|
|
Error: "batch upload failed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveBatchDistributorNotification() error = %v", err)
|
|
}
|
|
wantPath := filepath.Join("notifications", "batches", "morning", "2026-06-17", "20260617T120000.000000000Z_morning.distributor.json")
|
|
if !strings.Contains(path, wantPath) {
|
|
t.Fatalf("path = %q, want component %q", path, wantPath)
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read batch notification: %v", err)
|
|
}
|
|
var artifact BatchDistributorNotificationArtifact
|
|
if err := json.Unmarshal(data, &artifact); err != nil {
|
|
t.Fatalf("decode batch notification: %v", err)
|
|
}
|
|
if artifact.SchemaVersion != BatchDistributorNotificationSchemaVersion {
|
|
t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, BatchDistributorNotificationSchemaVersion)
|
|
}
|
|
if artifact.Batch != "morning" || artifact.BatchRunID != ref.BatchRunID {
|
|
t.Fatalf("artifact batch identity = %q/%q, want ref values", artifact.Batch, artifact.BatchRunID)
|
|
}
|
|
if artifact.Endpoint != "https://distributor.example.test" || artifact.PipelineID != "weatherreporter" || artifact.BundleID != "weatherreporter.home.morning" || artifact.IdempotencyKey == "" {
|
|
t.Fatalf("artifact identity = %#v, want distributor identity", artifact)
|
|
}
|
|
if len(artifact.Reports) != 2 || artifact.Reports[0].ReportID != report.Today || strings.Join(artifact.Reports[1].BundlePaths, ",") != "2026-06-19/daily/report.md" {
|
|
t.Fatalf("Reports = %#v, want included report records", artifact.Reports)
|
|
}
|
|
if artifact.Upload == nil || artifact.Upload.RunID != "distributor-run" {
|
|
t.Fatalf("Upload = %#v, want accepted upload result", artifact.Upload)
|
|
}
|
|
if artifact.RunStatus == nil || artifact.RunStatus.Status != "failed" || !strings.Contains(string(artifact.RunStatus.Report), "failed") || artifact.RunStatus.FinishedAt == nil {
|
|
t.Fatalf("RunStatus = %#v, want failed run status with raw report", artifact.RunStatus)
|
|
}
|
|
if artifact.StatusError != "status lookup failed" || artifact.Error != "batch upload failed" {
|
|
t.Fatalf("errors = %q/%q, want persisted error fields", artifact.StatusError, artifact.Error)
|
|
}
|
|
}
|
|
|
|
func TestBatchDistributorNotificationPathRejectsInvalidIdentity(t *testing.T) {
|
|
store := newTestStore(t)
|
|
location := mustLoadStateLocation(t, "America/Chicago")
|
|
valid := BatchDistributorNotificationRef{
|
|
Batch: "morning",
|
|
BatchRunID: "20260617T120000.000000000Z_morning",
|
|
StartedAt: time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC),
|
|
Location: location,
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*BatchDistributorNotificationRef)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "Batch",
|
|
mutate: func(ref *BatchDistributorNotificationRef) {
|
|
ref.Batch = ""
|
|
},
|
|
wantErr: "batch kind is required",
|
|
},
|
|
{
|
|
name: "BatchSeparator",
|
|
mutate: func(ref *BatchDistributorNotificationRef) {
|
|
ref.Batch = "../morning"
|
|
},
|
|
wantErr: "batch kind must not contain path separators",
|
|
},
|
|
{
|
|
name: "BatchRunID",
|
|
mutate: func(ref *BatchDistributorNotificationRef) {
|
|
ref.BatchRunID = ""
|
|
},
|
|
wantErr: "batch run id is required",
|
|
},
|
|
{
|
|
name: "BatchRunIDSeparator",
|
|
mutate: func(ref *BatchDistributorNotificationRef) {
|
|
ref.BatchRunID = "nested/run"
|
|
},
|
|
wantErr: "batch run id must not contain path separators",
|
|
},
|
|
{
|
|
name: "StartedAt",
|
|
mutate: func(ref *BatchDistributorNotificationRef) {
|
|
ref.StartedAt = time.Time{}
|
|
},
|
|
wantErr: "batch started time is required",
|
|
},
|
|
{
|
|
name: "Location",
|
|
mutate: func(ref *BatchDistributorNotificationRef) {
|
|
ref.Location = nil
|
|
},
|
|
wantErr: "batch location is required",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ref := valid
|
|
tt.mutate(&ref)
|
|
_, err := store.BatchDistributorNotificationPath(ref)
|
|
if err == nil {
|
|
t.Fatal("BatchDistributorNotificationPath() error = nil, want error")
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
|
|
store := newTestStore(t)
|
|
tests := []struct {
|
|
name string
|
|
resolved report.Resolved
|
|
group string
|
|
validDate string
|
|
runID string
|
|
}{
|
|
{
|
|
name: "daily",
|
|
resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"),
|
|
group: "daily",
|
|
validDate: "2026-05-29",
|
|
runID: "20260529T100000.000000000Z_daily_2026-05-29",
|
|
},
|
|
{
|
|
name: "hourly",
|
|
resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"),
|
|
group: "hourly",
|
|
validDate: "2026-05-29",
|
|
runID: "20260529T100000.000000000Z_hourly",
|
|
},
|
|
{
|
|
name: "tomorrow",
|
|
resolved: resolveTomorrowAt(t, "2026-05-29T18:00:00-05:00"),
|
|
group: "tomorrow",
|
|
validDate: "2026-05-30",
|
|
runID: "20260529T230000.000000000Z_tomorrow",
|
|
},
|
|
{
|
|
name: "today",
|
|
resolved: resolveTodayAt(t, "2026-05-29T05:00:00-05:00"),
|
|
group: "today",
|
|
validDate: "2026-05-29",
|
|
runID: "20260529T100000.000000000Z_today",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
paths, err := store.Paths(tt.resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
wants := map[string]string{
|
|
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"),
|
|
"RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"),
|
|
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"),
|
|
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"),
|
|
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"),
|
|
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"),
|
|
}
|
|
got := map[string]string{
|
|
"DataPackage": paths.DataPackage,
|
|
"RenderedReport": paths.RenderedReport,
|
|
"GeneratedTextRaw": paths.GeneratedTextRaw,
|
|
"GeneratedTextResult": paths.GeneratedTextResult,
|
|
"GeneratedText": paths.GeneratedText,
|
|
"RenderContext": paths.RenderContext,
|
|
}
|
|
for name, want := range wants {
|
|
if !strings.Contains(got[name], want) {
|
|
t.Fatalf("%s path = %q, want component %q", name, got[name], want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|
store := newTestStore(t)
|
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
|
briefingMetadata := stateBriefingMetadata(resolved)
|
|
snapshot, err := module.NewSnapshot([]module.Output{{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
Value: map[string]string{"run_id": resolved.Metadata().RunID},
|
|
PromptValue: map[string]string{"prompt_run_id": resolved.Metadata().RunID},
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("NewSnapshot() error = %v", err)
|
|
}
|
|
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
|
Metadata: promptinput.Metadata{
|
|
RunID: resolved.Metadata().RunID,
|
|
ReportID: resolved.Definition.ID,
|
|
Variant: briefingMetadata.Variant,
|
|
PromptID: resolved.Definition.PromptID,
|
|
GeneratedAt: resolved.GeneratedAt,
|
|
Timezone: resolved.Timezone,
|
|
ValidPeriod: resolved.ValidPeriod,
|
|
},
|
|
Modules: snapshot,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Build() error = %v", err)
|
|
}
|
|
|
|
dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage)
|
|
if err != nil {
|
|
t.Fatalf("SaveDataPackage() error = %v", err)
|
|
}
|
|
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
|
|
if err != nil {
|
|
t.Fatalf("SaveModuleSnapshot() error = %v", err)
|
|
}
|
|
preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
|
|
if err != nil {
|
|
t.Fatalf("SavePreflight() error = %v", err)
|
|
}
|
|
notificationPath, err := store.SaveDistributorNotification(context.Background(), resolved, DistributorNotificationArtifact{
|
|
RunID: resolved.Metadata().RunID,
|
|
ReportID: resolved.Definition.ID,
|
|
AttemptedAt: resolved.GeneratedAt,
|
|
Endpoint: "https://distributor.example.test",
|
|
PipelineID: "weatherreporter.daily",
|
|
BundleID: "weatherreporter.home.daily.run",
|
|
IdempotencyKey: "weatherreporter.home.daily.run",
|
|
SourcePath: "/tmp/report.md",
|
|
BundlePaths: []string{"2026-05-29/daily/report.md"},
|
|
BundleCreated: resolved.GeneratedAt,
|
|
Status: "succeeded",
|
|
RunStatus: &DistributorRunStatus{RunID: "distributor-run", Status: "succeeded"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveDistributorNotification() error = %v", err)
|
|
}
|
|
renderedReportPath, err := store.PrepareRenderedReport(context.Background(), resolved)
|
|
if err != nil {
|
|
t.Fatalf("PrepareRenderedReport() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
|
|
t.Fatalf("write rendered report: %v", err)
|
|
}
|
|
var preflight PreflightArtifact
|
|
preflightData, err := os.ReadFile(preflightPath)
|
|
if err != nil {
|
|
t.Fatalf("read preflight: %v", err)
|
|
}
|
|
if err := json.Unmarshal(preflightData, &preflight); err != nil {
|
|
t.Fatalf("decode preflight: %v", err)
|
|
}
|
|
if preflight.Stdout != `{"ok":true}` {
|
|
t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout)
|
|
}
|
|
var notification DistributorNotificationArtifact
|
|
notificationData, err := os.ReadFile(notificationPath)
|
|
if err != nil {
|
|
t.Fatalf("read notification: %v", err)
|
|
}
|
|
if err := json.Unmarshal(notificationData, ¬ification); err != nil {
|
|
t.Fatalf("decode notification: %v", err)
|
|
}
|
|
if notification.SchemaVersion != DistributorNotificationSchemaVersion || notification.PipelineID != "weatherreporter.daily" || len(notification.BundlePaths) != 1 || notification.RunStatus == nil || notification.RunStatus.Status != "succeeded" {
|
|
t.Fatalf("notification = %#v, want persisted distributor status", notification)
|
|
}
|
|
paths, err := store.Paths(resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
|
ModuleSnapshot: moduleSnapshotPath,
|
|
Metadata: paths.Metadata,
|
|
DataPackage: dataPackagePath,
|
|
Preflight: preflightPath,
|
|
RenderedReport: renderedReportPath,
|
|
})
|
|
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
|
|
for _, path := range []string{moduleSnapshotPath, dataPackagePath, preflightPath, notificationPath, renderedReportPath, metadataPath} {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("expected artifact %q: %v", path, err)
|
|
}
|
|
}
|
|
loadedSnapshot, err := store.LoadModuleSnapshot(context.Background(), moduleSnapshotPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadModuleSnapshot() error = %v", err)
|
|
}
|
|
if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 {
|
|
t.Fatalf("loaded module snapshot = %#v, want one metadata output", loadedSnapshot)
|
|
}
|
|
if loadedSnapshot.Outputs[0].PromptValue != nil {
|
|
t.Fatalf("loaded module snapshot PromptValue = %#v, want omitted runtime value", loadedSnapshot.Outputs[0].PromptValue)
|
|
}
|
|
snapshotData, err := os.ReadFile(moduleSnapshotPath)
|
|
if err != nil {
|
|
t.Fatalf("read module snapshot: %v", err)
|
|
}
|
|
if strings.Contains(string(snapshotData), "prompt_run_id") || strings.Contains(string(snapshotData), "promptValue") || strings.Contains(string(snapshotData), "PromptValue") {
|
|
t.Fatalf("module snapshot JSON includes runtime-only prompt value:\n%s", string(snapshotData))
|
|
}
|
|
loadedDataPackage, err := store.LoadDataPackage(context.Background(), dataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("LoadDataPackage() error = %v", err)
|
|
}
|
|
if loadedDataPackage.SchemaVersion != promptinput.SchemaVersion || loadedDataPackage.Briefing.Order[0] != "metadata" {
|
|
t.Fatalf("loaded data package = %#v, want YAML package with metadata stanza", loadedDataPackage)
|
|
}
|
|
metadataStanza, ok := loadedDataPackage.Briefing.Values["metadata"].(map[string]any)
|
|
if !ok || metadataStanza["prompt_run_id"] != resolved.Metadata().RunID {
|
|
t.Fatalf("loaded data package metadata = %#v, want runtime prompt value", loadedDataPackage.Briefing.Values["metadata"])
|
|
}
|
|
var decoded Metadata
|
|
data, err := os.ReadFile(metadataPath)
|
|
if err != nil {
|
|
t.Fatalf("read metadata: %v", err)
|
|
}
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("decode metadata: %v", err)
|
|
}
|
|
if decoded.RunID != resolved.Metadata().RunID {
|
|
t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID)
|
|
}
|
|
if decoded.ModuleSnapshotPath != moduleSnapshotPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
|
|
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
|
|
}
|
|
if decoded.RenderedReportPath != renderedReportPath {
|
|
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
|
}
|
|
if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" {
|
|
t.Fatalf("metadata location = %#v, want briefing location", decoded.Location)
|
|
}
|
|
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
|
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
|
}
|
|
if decoded.GeneratedTextSchemaID != "daily" {
|
|
t.Fatalf("GeneratedTextSchemaID = %q, want daily", decoded.GeneratedTextSchemaID)
|
|
}
|
|
for _, unexpected := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} {
|
|
if strings.Contains(string(data), unexpected) {
|
|
t.Fatalf("metadata JSON includes unsaved generated-text path field %q:\n%s", unexpected, string(data))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|
store := newTestStore(t)
|
|
resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
|
|
briefingMetadata := stateBriefingMetadata(resolved)
|
|
|
|
rawPath, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"summary":"raw"}`))
|
|
if err != nil {
|
|
t.Fatalf("SaveGeneratedTextRaw() error = %v", err)
|
|
}
|
|
resultPath, err := store.SaveGeneratedTextResult(context.Background(), resolved, map[string]any{
|
|
"command": []string{"scriptorium", "run"},
|
|
"status": "succeeded",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveGeneratedTextResult() error = %v", err)
|
|
}
|
|
generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`))
|
|
if err != nil {
|
|
t.Fatalf("SaveGeneratedText() error = %v", err)
|
|
}
|
|
contextPath, err := store.SaveRenderContext(context.Background(), resolved, struct {
|
|
ReportTitle string `json:"reportTitle"`
|
|
Location string `json:"location"`
|
|
}{
|
|
ReportTitle: "Hourly Report",
|
|
Location: "Brentwood",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveRenderContext() error = %v", err)
|
|
}
|
|
|
|
for _, path := range []string{rawPath, resultPath, generatedPath, contextPath} {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("expected generated-text artifact %q: %v", path, err)
|
|
}
|
|
}
|
|
rawData, err := store.LoadGeneratedText(context.Background(), rawPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadGeneratedText() raw error = %v", err)
|
|
}
|
|
if string(rawData) != `{"summary":"raw"}` {
|
|
t.Fatalf("raw data = %q, want saved bytes", rawData)
|
|
}
|
|
generatedData, err := store.LoadGeneratedText(context.Background(), generatedPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadGeneratedText() generated error = %v", err)
|
|
}
|
|
if !strings.Contains(string(generatedData), `"forecast_discussion":"A front will keep the region unsettled."`) {
|
|
t.Fatalf("generated text = %q, want saved normalized JSON", generatedData)
|
|
}
|
|
var runResult struct {
|
|
Command []string `json:"command"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := store.LoadGeneratedTextResult(context.Background(), resultPath, &runResult); err != nil {
|
|
t.Fatalf("LoadGeneratedTextResult() error = %v", err)
|
|
}
|
|
if runResult.Status != "succeeded" || strings.Join(runResult.Command, " ") != "scriptorium run" {
|
|
t.Fatalf("run result = %#v, want saved result", runResult)
|
|
}
|
|
var renderContext struct {
|
|
ReportTitle string `json:"reportTitle"`
|
|
Location string `json:"location"`
|
|
}
|
|
if err := store.LoadRenderContext(context.Background(), contextPath, &renderContext); err != nil {
|
|
t.Fatalf("LoadRenderContext() error = %v", err)
|
|
}
|
|
if renderContext.ReportTitle != "Hourly Report" || renderContext.Location != "Brentwood" {
|
|
t.Fatalf("render context = %#v, want saved context", renderContext)
|
|
}
|
|
|
|
paths, err := store.Paths(resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
|
ModuleSnapshot: paths.ModuleSnapshot,
|
|
Metadata: paths.Metadata,
|
|
DataPackage: paths.DataPackage,
|
|
Preflight: paths.Preflight,
|
|
RenderedReport: paths.RenderedReport,
|
|
GeneratedTextRaw: rawPath,
|
|
GeneratedTextResult: resultPath,
|
|
GeneratedText: generatedPath,
|
|
RenderContext: contextPath,
|
|
})
|
|
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
var decoded Metadata
|
|
metadataData, err := os.ReadFile(metadataPath)
|
|
if err != nil {
|
|
t.Fatalf("read metadata: %v", err)
|
|
}
|
|
if err := json.Unmarshal(metadataData, &decoded); err != nil {
|
|
t.Fatalf("decode metadata: %v", err)
|
|
}
|
|
if decoded.GeneratedTextSchemaID != "hourly" {
|
|
t.Fatalf("GeneratedTextSchemaID = %q, want hourly", decoded.GeneratedTextSchemaID)
|
|
}
|
|
if decoded.GeneratedTextRawPath != rawPath || decoded.GeneratedTextResultPath != resultPath || decoded.GeneratedTextPath != generatedPath || decoded.RenderContextPath != contextPath {
|
|
t.Fatalf("metadata generated-text paths = %#v, want saved artifact paths", decoded)
|
|
}
|
|
}
|
|
|
|
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
|
store := newTestStore(t)
|
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
|
briefingMetadata := stateBriefingMetadata(resolved)
|
|
paths, err := store.Paths(resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
|
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
|
|
|
|
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
|
ModuleSnapshot: paths.ModuleSnapshot,
|
|
Metadata: paths.Metadata,
|
|
DataPackage: paths.DataPackage,
|
|
Preflight: paths.Preflight,
|
|
RenderedReport: paths.RenderedReport,
|
|
})
|
|
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
if metadataPath != paths.Metadata {
|
|
t.Fatalf("SaveMetadata() path = %q, want explicit metadata path %q", metadataPath, paths.Metadata)
|
|
}
|
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
|
t.Fatalf("expected explicit metadata path %q: %v", paths.Metadata, err)
|
|
}
|
|
if _, err := os.Stat(derivedMetadataPath); !os.IsNotExist(err) {
|
|
t.Fatalf("derived metadata path stat error = %v, want not exist", err)
|
|
}
|
|
}
|
|
|
|
func TestFindPriorSnapshot(t *testing.T) {
|
|
store := newTestStore(t)
|
|
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
|
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
|
paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
|
|
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
|
if err != nil {
|
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
|
}
|
|
if prior == nil {
|
|
t.Fatal("FindPriorSnapshot() = nil, want prior snapshot")
|
|
}
|
|
if prior.Metadata.RunID != first.Metadata().RunID {
|
|
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
|
}
|
|
if prior.ModuleSnapshotPath != paths.ModuleSnapshot {
|
|
t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot)
|
|
}
|
|
}
|
|
|
|
func TestFindPriorSnapshotSupportsToday(t *testing.T) {
|
|
store := newTestStore(t)
|
|
first := resolveTodayAt(t, "2026-05-29T05:00:00-05:00")
|
|
second := resolveTodayAt(t, "2026-05-29T08:00:00-05:00")
|
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
|
|
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
|
if err != nil {
|
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
|
}
|
|
if prior == nil {
|
|
t.Fatal("FindPriorSnapshot() = nil, want prior Today snapshot")
|
|
}
|
|
if prior.Metadata.RunID != first.Metadata().RunID {
|
|
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
|
}
|
|
}
|
|
|
|
func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
|
|
store := newTestStore(t)
|
|
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
|
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
|
savePriorMetadata(t, store, previousDate, stateBriefingMetadata(previousDate))
|
|
|
|
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
|
|
if err != nil {
|
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
|
}
|
|
if prior != nil {
|
|
t.Fatalf("FindPriorSnapshot() = %#v, want nil for different valid date", prior)
|
|
}
|
|
}
|
|
|
|
func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) {
|
|
store := newTestStore(t)
|
|
first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00")
|
|
second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00")
|
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
|
|
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
|
if err != nil {
|
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
|
}
|
|
if prior == nil {
|
|
t.Fatal("FindPriorSnapshot() = nil, want prior 3-day snapshot")
|
|
}
|
|
if prior.Metadata.RunID != first.Metadata().RunID {
|
|
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
|
}
|
|
}
|
|
|
|
func TestFindPriorSnapshotSupportsWeekend(t *testing.T) {
|
|
store := newTestStore(t)
|
|
first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00")
|
|
second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00")
|
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
|
|
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
|
if err != nil {
|
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
|
}
|
|
if prior == nil {
|
|
t.Fatal("FindPriorSnapshot() = nil, want prior weekend snapshot")
|
|
}
|
|
if prior.Metadata.RunID != first.Metadata().RunID {
|
|
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
|
}
|
|
}
|
|
|
|
func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
|
|
store := newTestStore(t)
|
|
first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00")
|
|
second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00")
|
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
|
|
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
|
if err != nil {
|
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
|
}
|
|
if prior == nil {
|
|
t.Fatal("FindPriorSnapshot() = nil, want prior narrowed weekend snapshot")
|
|
}
|
|
if prior.Metadata.RunID != first.Metadata().RunID {
|
|
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
|
}
|
|
}
|
|
|
|
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
|
|
store := newTestStore(t)
|
|
first := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
|
|
second := resolveHourlyAt(t, "2026-05-29T06:00:00-05:00")
|
|
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
|
|
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
|
if err != nil {
|
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
|
}
|
|
if prior != nil {
|
|
t.Fatalf("FindPriorSnapshot() = %#v, want nil for rolling-window comparison", prior)
|
|
}
|
|
}
|
|
|
|
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
|
cfg := config.Defaults().Workspace
|
|
cfg.Root = t.TempDir()
|
|
cfg.SnapshotsDir = "../snapshots"
|
|
|
|
_, err := NewFilesystemStore(cfg)
|
|
if err == nil {
|
|
t.Fatal("NewFilesystemStore() error = nil, want unsafe path error")
|
|
}
|
|
if !strings.Contains(err.Error(), "within workspace root") {
|
|
t.Fatalf("error = %q, want path safety context", err.Error())
|
|
}
|
|
}
|
|
|
|
func newTestStore(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 mustLoadStateLocation(t *testing.T, name string) *time.Location {
|
|
t.Helper()
|
|
location, err := time.LoadLocation(name)
|
|
if err != nil {
|
|
t.Fatalf("LoadLocation(%q) error = %v", name, err)
|
|
}
|
|
return location
|
|
}
|
|
|
|
func resolveDailyAt(t *testing.T, value string) report.Resolved {
|
|
t.Helper()
|
|
return resolveDailyForDateAt(t, value, value)
|
|
}
|
|
|
|
func resolveDailyForDateAt(t *testing.T, nowValue string, dateValue string) report.Resolved {
|
|
t.Helper()
|
|
location, err := timeutil.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 time: %v", err)
|
|
}
|
|
date, err := time.Parse(time.RFC3339, dateValue)
|
|
if err != nil {
|
|
t.Fatalf("parse date time: %v", err)
|
|
}
|
|
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
|
Now: now,
|
|
Location: location,
|
|
Date: date,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve() error = %v", err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func resolveTodayAt(t *testing.T, value string) report.Resolved {
|
|
t.Helper()
|
|
location, err := timeutil.LoadLocation("America/Chicago")
|
|
if err != nil {
|
|
t.Fatalf("LoadLocation() error = %v", err)
|
|
}
|
|
now, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
t.Fatalf("parse time: %v", err)
|
|
}
|
|
resolved, err := report.DefaultRegistry().Resolve(report.Today, report.ResolveRequest{
|
|
Now: now,
|
|
Location: location,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve() error = %v", err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func resolveThreeDayAt(t *testing.T, value string) report.Resolved {
|
|
t.Helper()
|
|
location, err := timeutil.LoadLocation("America/Chicago")
|
|
if err != nil {
|
|
t.Fatalf("LoadLocation() error = %v", err)
|
|
}
|
|
now, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
t.Fatalf("parse time: %v", err)
|
|
}
|
|
resolved, err := report.DefaultRegistry().Resolve(report.ThreeDay, report.ResolveRequest{
|
|
Now: now,
|
|
Location: location,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve() error = %v", err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func resolveWeekendAt(t *testing.T, value string) report.Resolved {
|
|
t.Helper()
|
|
location, err := timeutil.LoadLocation("America/Chicago")
|
|
if err != nil {
|
|
t.Fatalf("LoadLocation() error = %v", err)
|
|
}
|
|
now, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
t.Fatalf("parse time: %v", err)
|
|
}
|
|
resolved, err := report.DefaultRegistry().Resolve(report.Weekend, report.ResolveRequest{
|
|
Now: now,
|
|
Location: location,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve() error = %v", err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func resolveHourlyAt(t *testing.T, value string) report.Resolved {
|
|
t.Helper()
|
|
location, err := timeutil.LoadLocation("America/Chicago")
|
|
if err != nil {
|
|
t.Fatalf("LoadLocation() error = %v", err)
|
|
}
|
|
now, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
t.Fatalf("parse time: %v", err)
|
|
}
|
|
resolved, err := report.DefaultRegistry().Resolve(report.Hourly, report.ResolveRequest{
|
|
Now: now,
|
|
Location: location,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve() error = %v", err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func resolveTomorrowAt(t *testing.T, value string) report.Resolved {
|
|
t.Helper()
|
|
location, err := timeutil.LoadLocation("America/Chicago")
|
|
if err != nil {
|
|
t.Fatalf("LoadLocation() error = %v", err)
|
|
}
|
|
now, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
t.Fatalf("parse time: %v", err)
|
|
}
|
|
resolved, err := report.DefaultRegistry().Resolve(report.Tomorrow, report.ResolveRequest{
|
|
Now: now,
|
|
Location: location,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve() error = %v", err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
|
return briefing.Metadata{
|
|
RunID: resolved.Metadata().RunID,
|
|
ReportID: resolved.Definition.ID,
|
|
Variant: "today",
|
|
PromptID: resolved.Definition.PromptID,
|
|
GeneratedAt: resolved.GeneratedAt,
|
|
Units: "us",
|
|
Timezone: resolved.Timezone,
|
|
Location: &briefing.LocationContext{
|
|
ID: "home",
|
|
Name: "Brentwood",
|
|
Region: "St. Louis Metro",
|
|
Timezone: resolved.Timezone,
|
|
},
|
|
ValidPeriod: resolved.ValidPeriod,
|
|
}
|
|
}
|
|
|
|
func savePriorMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved, metadata briefing.Metadata) ArtifactPaths {
|
|
t.Helper()
|
|
paths, err := store.Paths(resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
_, err = store.SaveMetadata(context.Background(), BuildMetadataFromBriefingMetadata(resolved, metadata, ArtifactPaths{
|
|
ModuleSnapshot: paths.ModuleSnapshot,
|
|
Metadata: paths.Metadata,
|
|
DataPackage: paths.DataPackage,
|
|
Preflight: paths.Preflight,
|
|
RenderedReport: paths.RenderedReport,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func pathsString(paths ArtifactPaths) string {
|
|
return strings.Join([]string{
|
|
paths.Metadata,
|
|
paths.ModuleSnapshot,
|
|
paths.DataPackage,
|
|
paths.Preflight,
|
|
paths.Notification,
|
|
paths.RenderedReport,
|
|
paths.GeneratedTextRaw,
|
|
paths.GeneratedTextResult,
|
|
paths.GeneratedText,
|
|
paths.RenderContext,
|
|
}, "\n")
|
|
}
|