Harden scheduled report runs

This commit is contained in:
2026-05-29 18:20:39 +00:00
parent 5d543b6b4d
commit 108a1618f6
6 changed files with 416 additions and 19 deletions

View File

@@ -3,6 +3,7 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
@@ -283,6 +284,92 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
}
}
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFailingScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"run", "morning",
"--config", configPath,
}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want aggregate failure")
}
if !strings.Contains(err.Error(), "1 of 3 reports failed") {
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
}
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed)
}
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
}
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
if err != nil {
t.Fatalf("glob daily packages: %v", err)
}
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
if err != nil {
t.Fatalf("glob weekend packages: %v", err)
}
if len(dailyPackages) != 1 || len(weekendPackages) != 1 {
t.Fatalf("daily packages = %#v, weekend packages = %#v; want successful reports to continue", dailyPackages, weekendPackages)
}
}
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
outputDir := filepath.Join(tempDir, "copies")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"run", "evening",
"--config", configPath,
"--out-dir", outputDir,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
}
if summary.Total != 1 || summary.Failed != 0 {
t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed)
}
if _, err := os.Stat(filepath.Join(outputDir, "tomorrow.md")); err != nil {
t.Fatalf("expected copied report: %v", err)
}
if len(summary.Reports) != 1 || summary.Reports[0].OutputPath != filepath.Join(outputDir, "tomorrow.md") {
t.Fatalf("summary reports = %#v, want output path", summary.Reports)
}
}
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
@@ -505,6 +592,16 @@ func TestResolveRunRejectsOutputFlag(t *testing.T) {
}
}
func TestResolveRunAppliesOutputDirectory(t *testing.T) {
req, err := resolveRun([]string{"evening", "--out-dir", "./reports"})
if err != nil {
t.Fatalf("resolveRun() error = %v", err)
}
if req.OutputDir != "./reports" {
t.Fatalf("OutputDir = %q, want ./reports", req.OutputDir)
}
}
func fixedClock() timeutil.Clock {
return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)}
}
@@ -562,3 +659,44 @@ exit 1
}
return path
}
func writeFailingScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")
body := `#!/bin/sh
if [ "$1" = "render" ]; then
prompt=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi
shift
done
if [ "$prompt" = "weather.three_day_outlook" ]; then
printf 'render failed\n' >&2
exit 1
fi
printf '{"ok":true,"prompt":"%s"}' "$prompt"
exit 0
fi
if [ "$1" = "run" ]; then
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
fi
shift
done
printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out"
exit 0
fi
printf 'unexpected command\n' >&2
exit 1
`
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
t.Fatalf("write fake scriptorium: %v", err)
}
return path
}