344 lines
11 KiB
Go
344 lines
11 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
|
)
|
|
|
|
func TestRunDryRunPrintsConfigSummary(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
|
configPath := writeLocalConfig(t, sourceRoot, destinationRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
err := Run(context.Background(), RunOptions{
|
|
ConfigPath: configPath,
|
|
DryRun: true,
|
|
Stdout: &stdout,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
output := stdout.String()
|
|
for _, want := range []string{
|
|
"Configured pipelines: 1",
|
|
"- reports: source=local bundles=1 destinations=1",
|
|
"bundle=. destination=archive action=publish_new",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("Run() output = %q, want substring %q", output, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunPublishesNewLocalBundle(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
|
|
|
var stdout bytes.Buffer
|
|
err := Run(context.Background(), RunOptions{
|
|
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
|
Stdout: &stdout,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
assertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
|
assertFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n")
|
|
if _, err := os.Stat(filepath.Join(destinationRoot, "manifest.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("destination manifest stat error = %v, want not exist", err)
|
|
}
|
|
destinationState := readStateFile(t, filepath.Join(destinationRoot, ".distributor.json"))
|
|
if destinationState.PipelineID != "reports" || destinationState.DestinationID != "archive" {
|
|
t.Fatalf("state identity = %s/%s", destinationState.PipelineID, destinationState.DestinationID)
|
|
}
|
|
if destinationState.Source.Manifest.ID != manifest.ID {
|
|
t.Fatalf("state source id = %q, want %q", destinationState.Source.Manifest.ID, manifest.ID)
|
|
}
|
|
if got, want := len(destinationState.Outputs), 2; got != want {
|
|
t.Fatalf("state output count = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRunSkipsWhenDestinationStateMatches(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
|
configPath := writeLocalConfig(t, sourceRoot, destinationRoot)
|
|
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
|
t.Fatalf("first Run() error = %v", err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
err := Run(context.Background(), RunOptions{ConfigPath: configPath, Stdout: &stdout})
|
|
if err != nil {
|
|
t.Fatalf("second Run() error = %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "action=skip_same") {
|
|
t.Fatalf("stdout = %q, want skip_same", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunReplacesOlderDestination(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
|
older := manifest
|
|
older.Created = older.Created.Add(-time.Hour)
|
|
writeDestinationState(t, destinationRoot, "", older)
|
|
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("old\n"), 0o600); err != nil {
|
|
t.Fatalf("write old output: %v", err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot), Stdout: &stdout})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "action=replace_older") {
|
|
t.Fatalf("stdout = %q, want replace_older", stdout.String())
|
|
}
|
|
assertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
|
}
|
|
|
|
func TestRunSkipsNewerDestination(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
|
newer := manifest
|
|
newer.Created = newer.Created.Add(time.Hour)
|
|
writeDestinationState(t, destinationRoot, "", newer)
|
|
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("newer\n"), 0o600); err != nil {
|
|
t.Fatalf("write newer output: %v", err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot), Stdout: &stdout})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "action=skip_destination_newer") {
|
|
t.Fatalf("stdout = %q, want skip_destination_newer", stdout.String())
|
|
}
|
|
assertFile(t, filepath.Join(destinationRoot, "report.md"), "newer\n")
|
|
}
|
|
|
|
func TestRunFailsOnConflict(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
|
manifest.ID = "other.source"
|
|
writeDestinationState(t, destinationRoot, "", manifest)
|
|
|
|
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot)})
|
|
if err == nil || !strings.Contains(err.Error(), "fail_conflict") {
|
|
t.Fatalf("Run() error = %v, want fail_conflict", err)
|
|
}
|
|
}
|
|
|
|
func TestRunFailsOnUnmanagedDestination(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
|
if err := os.WriteFile(filepath.Join(destinationRoot, "unmanaged.txt"), []byte("data"), 0o600); err != nil {
|
|
t.Fatalf("write unmanaged file: %v", err)
|
|
}
|
|
|
|
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot)})
|
|
if err == nil || !strings.Contains(err.Error(), "fail_unmanaged") {
|
|
t.Fatalf("Run() error = %v, want fail_unmanaged", err)
|
|
}
|
|
}
|
|
|
|
func TestRunFansOutToLocalDestinations(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
firstDestination := t.TempDir()
|
|
secondDestination := t.TempDir()
|
|
writeSourceBundle(t, sourceRoot, "daily", testBundleOptions{})
|
|
|
|
err := Run(context.Background(), RunOptions{ConfigPath: writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination)})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
assertFile(t, filepath.Join(firstDestination, "daily", "report.md"), "# Report\nSunny.\n")
|
|
assertFile(t, filepath.Join(secondDestination, "daily", "summary.txt"), "Summary\n")
|
|
}
|
|
|
|
func TestRunDryRunDoesNotWrite(t *testing.T) {
|
|
sourceRoot := t.TempDir()
|
|
destinationRoot := t.TempDir()
|
|
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
|
|
|
err := Run(context.Background(), RunOptions{
|
|
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
|
DryRun: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if entries, err := os.ReadDir(destinationRoot); err != nil || len(entries) != 0 {
|
|
t.Fatalf("destination entries = %v err=%v, want empty", entries, err)
|
|
}
|
|
}
|
|
|
|
type testBundleOptions struct {
|
|
ID string
|
|
Created time.Time
|
|
}
|
|
|
|
func writeSourceBundle(t *testing.T, root, relative string, opts testBundleOptions) bundle.Manifest {
|
|
t.Helper()
|
|
if opts.ID == "" {
|
|
opts.ID = "weather.daily.brentwood.2026-05-30"
|
|
}
|
|
if opts.Created.IsZero() {
|
|
opts.Created = time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC)
|
|
}
|
|
bundleRoot := filepath.Join(root, filepath.FromSlash(relative))
|
|
if err := os.MkdirAll(bundleRoot, 0o755); err != nil {
|
|
t.Fatalf("mkdir bundle: %v", err)
|
|
}
|
|
files := []struct {
|
|
path string
|
|
data string
|
|
}{
|
|
{path: "report.md", data: "# Report\nSunny.\n"},
|
|
{path: "summary.txt", data: "Summary\n"},
|
|
}
|
|
manifestFiles := make([]bundle.ManifestFile, 0, len(files))
|
|
for _, file := range files {
|
|
if err := os.WriteFile(filepath.Join(bundleRoot, filepath.FromSlash(file.path)), []byte(file.data), 0o600); err != nil {
|
|
t.Fatalf("write source file: %v", err)
|
|
}
|
|
manifestFiles = append(manifestFiles, bundle.ManifestFile{
|
|
Path: file.path,
|
|
SHA256: bundle.FileDigest([]byte(file.data)),
|
|
Size: int64(len(file.data)),
|
|
})
|
|
}
|
|
manifest := bundle.Manifest{
|
|
SchemaVersion: 1,
|
|
ID: opts.ID,
|
|
Created: opts.Created,
|
|
Files: manifestFiles,
|
|
}
|
|
manifest.Digest = bundle.BundleDigest(manifest.Files)
|
|
data, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal manifest: %v", err)
|
|
}
|
|
data = append(data, '\n')
|
|
if err := os.WriteFile(filepath.Join(bundleRoot, "manifest.json"), data, 0o600); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
return manifest
|
|
}
|
|
|
|
func writeLocalConfig(t *testing.T, sourceRoot, destinationRoot string) string {
|
|
t.Helper()
|
|
return writeConfigFile(t, `
|
|
pipelines:
|
|
- id: reports
|
|
source:
|
|
backend: local
|
|
path: `+sourceRoot+`
|
|
destinations:
|
|
- id: archive
|
|
backend: local
|
|
path: `+destinationRoot+`
|
|
publish:
|
|
source: true
|
|
html: false
|
|
`)
|
|
}
|
|
|
|
func writeFanoutConfig(t *testing.T, sourceRoot, firstDestination, secondDestination string) string {
|
|
t.Helper()
|
|
return writeConfigFile(t, `
|
|
pipelines:
|
|
- id: reports
|
|
source:
|
|
backend: local
|
|
path: `+sourceRoot+`
|
|
destinations:
|
|
- id: archive-one
|
|
backend: local
|
|
path: `+firstDestination+`
|
|
- id: archive-two
|
|
backend: local
|
|
path: `+secondDestination+`
|
|
`)
|
|
}
|
|
|
|
func writeConfigFile(t *testing.T, body string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "config.yml")
|
|
if err := os.WriteFile(path, []byte(strings.TrimSpace(body)+"\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func writeDestinationState(t *testing.T, root, relative string, manifest bundle.Manifest) {
|
|
t.Helper()
|
|
bundleRoot := filepath.Join(root, filepath.FromSlash(relative))
|
|
if err := os.MkdirAll(bundleRoot, 0o755); err != nil {
|
|
t.Fatalf("mkdir destination: %v", err)
|
|
}
|
|
destinationState := state.DistributorState{
|
|
SchemaVersion: state.SchemaVersion,
|
|
PipelineID: "reports",
|
|
DestinationID: "archive",
|
|
PublishedAt: time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC),
|
|
Source: state.SourceState{Manifest: manifest},
|
|
Outputs: []state.OutputFile{
|
|
{Path: "report.md", Kind: state.OutputKindSource, SourcePath: "report.md", SHA256: manifest.Files[0].SHA256, Size: manifest.Files[0].Size},
|
|
{Path: "summary.txt", Kind: state.OutputKindSource, SourcePath: "summary.txt", SHA256: manifest.Files[1].SHA256, Size: manifest.Files[1].Size},
|
|
},
|
|
}
|
|
data, err := json.MarshalIndent(destinationState, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal state: %v", err)
|
|
}
|
|
data = append(data, '\n')
|
|
if err := os.WriteFile(filepath.Join(bundleRoot, ".distributor.json"), data, 0o600); err != nil {
|
|
t.Fatalf("write state: %v", err)
|
|
}
|
|
}
|
|
|
|
func readStateFile(t *testing.T, path string) state.DistributorState {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read state: %v", err)
|
|
}
|
|
destinationState, err := state.Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("parse state: %v", err)
|
|
}
|
|
return destinationState
|
|
}
|
|
|
|
func assertFile(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read file %s: %v", path, err)
|
|
}
|
|
if got := string(data); got != want {
|
|
t.Fatalf("%s = %q, want %q", path, got, want)
|
|
}
|
|
}
|