442 lines
15 KiB
Go
442 lines
15 KiB
Go
package state
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
|
)
|
|
|
|
func TestParseValidState(t *testing.T) {
|
|
state, err := Parse([]byte(validStateJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
if state.SchemaVersion != SchemaVersion {
|
|
t.Fatalf("schema version = %d, want %d", state.SchemaVersion, SchemaVersion)
|
|
}
|
|
if state.PipelineID != "reports" || state.DestinationID != "archive" {
|
|
t.Fatalf("identity = %q/%q", state.PipelineID, state.DestinationID)
|
|
}
|
|
if got, want := state.PublishedAtString(), "2026-05-30T11:12:00Z"; got != want {
|
|
t.Fatalf("PublishedAtString() = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.CreatedAtString(), "2026-05-30T11:12:00Z"; got != want {
|
|
t.Fatalf("CreatedAtString() = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.UpdatedAtString(), "2026-05-30T11:12:00Z"; got != want {
|
|
t.Fatalf("UpdatedAtString() = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.State.Mode, StateModeSingleOwner; got != want {
|
|
t.Fatalf("state mode = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.Reconciliation.Mode, config.ReconciliationModeReplace; got != want {
|
|
t.Fatalf("reconciliation mode = %q, want %q", got, want)
|
|
}
|
|
if got, want := len(state.Outputs), 1; got != want {
|
|
t.Fatalf("output count = %d, want %d", got, want)
|
|
}
|
|
if got, want := state.Outputs[0].CreatedAtString(), "2026-05-30T11:12:00Z"; got != want {
|
|
t.Fatalf("output CreatedAtString() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseValidStateWithLinks(t *testing.T) {
|
|
body := strings.Replace(validStateJSON(t), `"outputs": [`, `"links": {"primary_url": "https://reports.example.com/archive/report.md"},`+"\n "+`"outputs": [`, 1)
|
|
body = strings.Replace(body, `"source_path": "report.md",`, `"source_path": "report.md",`+"\n "+`"url": "https://reports.example.com/archive/report.md",`, 1)
|
|
|
|
state, err := Parse([]byte(body))
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
if state.Links == nil || state.Links.PrimaryURL != "https://reports.example.com/archive/report.md" {
|
|
t.Fatalf("links = %#v, want primary URL", state.Links)
|
|
}
|
|
if state.Outputs[0].URL != "https://reports.example.com/archive/report.md" {
|
|
t.Fatalf("output URL = %q", state.Outputs[0].URL)
|
|
}
|
|
}
|
|
|
|
func TestParseNormalizesPublishedAtOffset(t *testing.T) {
|
|
body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "2026-05-30T13:12:00+02:00"`, 1)
|
|
state, err := Parse([]byte(body))
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
if got, want := state.PublishedAtString(), "2026-05-30T11:12:00Z"; got != want {
|
|
t.Fatalf("PublishedAtString() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsMissingFields(t *testing.T) {
|
|
tests := map[string]string{
|
|
"schema_version": `"schema_version"`,
|
|
"pipeline_id": `"pipeline_id"`,
|
|
"destination_id": `"destination_id"`,
|
|
"published_at": `"published_at"`,
|
|
"created_at": `"created_at"`,
|
|
"updated_at": `"updated_at"`,
|
|
"state": `"state"`,
|
|
"reconciliation": `"reconciliation"`,
|
|
"source": `"source"`,
|
|
"outputs": `"outputs"`,
|
|
}
|
|
for name, field := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
body := strings.Replace(validStateJSON(t), field, `"missing_`+name+`"`, 1)
|
|
_, err := Parse([]byte(body))
|
|
assertStateErrorContains(t, err, "required")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsInvalidSchemaVersion(t *testing.T) {
|
|
body := strings.Replace(validStateJSON(t), `"schema_version": 2`, `"schema_version": 3`, 1)
|
|
_, err := Parse([]byte(body))
|
|
assertStateErrorContains(t, err, "schema_version must be 1 or 2")
|
|
}
|
|
|
|
func TestParseLegacyStateInfersSingleOwnerDefaults(t *testing.T) {
|
|
state, err := Parse([]byte(legacyStateJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
if got, want := state.SchemaVersion, SchemaVersion; got != want {
|
|
t.Fatalf("schema version = %d, want normalized %d", got, want)
|
|
}
|
|
if got, want := state.CreatedAtString(), "2026-05-30T11:12:00Z"; got != want {
|
|
t.Fatalf("created_at = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.State.Mode, StateModeSingleOwner; got != want {
|
|
t.Fatalf("state mode = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.Reconciliation.Mode, config.ReconciliationModeReplace; got != want {
|
|
t.Fatalf("reconciliation mode = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.Outputs[0].UpdatedAtString(), "2026-05-30T11:12:00Z"; got != want {
|
|
t.Fatalf("output updated_at = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsInvalidEmbeddedManifest(t *testing.T) {
|
|
body := validStateWithManifestJSON(t, strings.Replace(manifestJSON(t), `"schema_version": 1`, `"schema_version": 2`, 1))
|
|
_, err := Parse([]byte(body))
|
|
assertStateErrorContains(t, err, "source.manifest")
|
|
}
|
|
|
|
func TestValidateRejectsInvalidEmbeddedManifest(t *testing.T) {
|
|
tests := map[string]func(bundle.Manifest) bundle.Manifest{
|
|
"schema version": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.SchemaVersion = 2
|
|
return manifest
|
|
},
|
|
"empty id": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.ID = ""
|
|
return manifest
|
|
},
|
|
"bad digest": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.Digest = "SHA256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe"
|
|
return manifest
|
|
},
|
|
"zero created": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.Created = time.Time{}
|
|
return manifest
|
|
},
|
|
"empty files": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.Files = nil
|
|
manifest.Digest = bundle.BundleDigest(manifest.Files)
|
|
return manifest
|
|
},
|
|
"unsafe path": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.Files[0].Path = "../report.md"
|
|
manifest.Digest = bundle.BundleDigest(manifest.Files)
|
|
return manifest
|
|
},
|
|
"duplicate path": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.Files[1].Path = manifest.Files[0].Path
|
|
manifest.Digest = bundle.BundleDigest(manifest.Files)
|
|
return manifest
|
|
},
|
|
"negative size": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.Files[0].Size = -1
|
|
manifest.Digest = bundle.BundleDigest(manifest.Files)
|
|
return manifest
|
|
},
|
|
"digest mismatch": func(manifest bundle.Manifest) bundle.Manifest {
|
|
manifest.Digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
return manifest
|
|
},
|
|
}
|
|
for name, mutate := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
source := mutate(validManifest(t))
|
|
state := *withState(t, validManifest(t), func(*DistributorState) {})
|
|
state.Source.Manifest = source
|
|
err := Validate(state)
|
|
assertStateErrorContains(t, err, "source.manifest")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsInvalidOutputMetadata(t *testing.T) {
|
|
source := validManifest(t)
|
|
tests := map[string]func(*DistributorState){
|
|
"unsafe path": func(s *DistributorState) {
|
|
s.Outputs[0].Path = "../report.md"
|
|
},
|
|
"invalid kind": func(s *DistributorState) {
|
|
s.Outputs[0].Kind = "other"
|
|
},
|
|
"invalid source": func(s *DistributorState) {
|
|
s.Outputs[0].SourcePath = "../report.md"
|
|
},
|
|
"generated missing": func(s *DistributorState) {
|
|
s.Outputs[0].Kind = OutputKindGenerated
|
|
},
|
|
"invalid digest": func(s *DistributorState) {
|
|
s.Outputs[0].SHA256 = "SHA256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6"
|
|
},
|
|
"negative size": func(s *DistributorState) {
|
|
s.Outputs[0].Size = -1
|
|
},
|
|
"missing output created_at": func(s *DistributorState) {
|
|
s.Outputs[0].CreatedAt = time.Time{}
|
|
},
|
|
"missing output updated_at": func(s *DistributorState) {
|
|
s.Outputs[0].UpdatedAt = time.Time{}
|
|
},
|
|
"invalid output url": func(s *DistributorState) {
|
|
s.Outputs[0].URL = "file:///tmp/report.md"
|
|
},
|
|
"invalid primary url": func(s *DistributorState) {
|
|
s.Links = &LinkState{PrimaryURL: "file:///tmp/report.md"}
|
|
},
|
|
}
|
|
for name, mutate := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
state := *withState(t, source, mutate)
|
|
err := Validate(state)
|
|
if err == nil {
|
|
t.Fatal("Validate() error = nil, want error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateReportsURLFieldContext(t *testing.T) {
|
|
source := validManifest(t)
|
|
state := *withState(t, source, func(s *DistributorState) {
|
|
s.Links = &LinkState{PrimaryURL: "https://reports.example.com/archive#top"}
|
|
})
|
|
err := Validate(state)
|
|
assertStateErrorContains(t, err, "state links.primary_url")
|
|
assertStateErrorContains(t, err, "must not include a fragment")
|
|
|
|
state = *withState(t, source, func(s *DistributorState) {
|
|
s.Outputs[0].URL = "https://reports.example.com/archive?preview=1"
|
|
})
|
|
err = Validate(state)
|
|
assertStateErrorContains(t, err, "state outputs[0].url")
|
|
assertStateErrorContains(t, err, "must not include a query string")
|
|
}
|
|
|
|
func TestParseRejectsMalformedPublishedTimestamp(t *testing.T) {
|
|
body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "May 30"`, 1)
|
|
_, err := Parse([]byte(body))
|
|
assertStateErrorContains(t, err, "published_at must be RFC3339")
|
|
}
|
|
|
|
func TestParseRejectsMalformedCreatedTimestamp(t *testing.T) {
|
|
body := strings.Replace(validStateJSON(t), `"created_at": "2026-05-30T11:12:00Z"`, `"created_at": "May 30"`, 1)
|
|
_, err := Parse([]byte(body))
|
|
assertStateErrorContains(t, err, "created_at must be RFC3339")
|
|
}
|
|
|
|
func TestMarshalNormalizesPublishedAtUTC(t *testing.T) {
|
|
source := validManifest(t)
|
|
publishedAt := time.Date(2026, 5, 30, 13, 12, 0, 0, time.FixedZone("offset", 2*60*60))
|
|
state := DistributorState{
|
|
SchemaVersion: SchemaVersion,
|
|
PipelineID: "reports",
|
|
DestinationID: "archive",
|
|
PublishedAt: publishedAt,
|
|
CreatedAt: publishedAt,
|
|
UpdatedAt: publishedAt,
|
|
State: StatePolicy{Mode: StateModeSingleOwner},
|
|
Reconciliation: ReconciliationPolicy{Mode: config.ReconciliationModeReplace},
|
|
Source: SourceState{Manifest: source},
|
|
Outputs: []OutputFile{{
|
|
Path: "report.md",
|
|
Kind: OutputKindSource,
|
|
SourcePath: "report.md",
|
|
SHA256: source.Files[0].SHA256,
|
|
Size: source.Files[0].Size,
|
|
CreatedAt: publishedAt,
|
|
UpdatedAt: publishedAt,
|
|
}},
|
|
}
|
|
data, err := json.Marshal(state)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"published_at":"2026-05-30T11:12:00Z"`) {
|
|
t.Fatalf("json = %s, want UTC RFC3339 published_at", data)
|
|
}
|
|
}
|
|
|
|
func TestMarshalIncludesLinksWhenPresent(t *testing.T) {
|
|
source := validManifest(t)
|
|
publishedAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC)
|
|
state := DistributorState{
|
|
SchemaVersion: SchemaVersion,
|
|
PipelineID: "reports",
|
|
DestinationID: "archive",
|
|
PublishedAt: publishedAt,
|
|
CreatedAt: publishedAt,
|
|
UpdatedAt: publishedAt,
|
|
State: StatePolicy{Mode: StateModeSingleOwner},
|
|
Reconciliation: ReconciliationPolicy{Mode: config.ReconciliationModeReplace},
|
|
Source: SourceState{Manifest: source},
|
|
Links: &LinkState{PrimaryURL: "https://reports.example.com/archive/report.md"},
|
|
Outputs: []OutputFile{{
|
|
Path: "report.md",
|
|
Kind: OutputKindSource,
|
|
SourcePath: "report.md",
|
|
URL: "https://reports.example.com/archive/report.md",
|
|
SHA256: source.Files[0].SHA256,
|
|
Size: source.Files[0].Size,
|
|
CreatedAt: publishedAt,
|
|
UpdatedAt: publishedAt,
|
|
}},
|
|
}
|
|
data, err := json.Marshal(state)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"links":{"primary_url":"https://reports.example.com/archive/report.md"}`) {
|
|
t.Fatalf("json = %s, want links primary URL", data)
|
|
}
|
|
if !strings.Contains(string(data), `"url":"https://reports.example.com/archive/report.md"`) {
|
|
t.Fatalf("json = %s, want output URL", data)
|
|
}
|
|
}
|
|
|
|
func TestOutputHelpers(t *testing.T) {
|
|
createdAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC)
|
|
updatedAt := createdAt.Add(time.Hour)
|
|
retained := []OutputFile{{
|
|
Path: "old.md",
|
|
Kind: OutputKindSource,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
}, {
|
|
Path: "report.md",
|
|
Kind: OutputKindSource,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
}}
|
|
projected := ProjectOutputs([]OutputProjection{{
|
|
Path: "report.md",
|
|
Kind: OutputKindSource,
|
|
}, {
|
|
Path: "new.md",
|
|
Kind: OutputKindSource,
|
|
}}, retained, updatedAt)
|
|
if got, ok := FindOutputByPath(projected, "report.md"); !ok || !got.CreatedAt.Equal(createdAt) || !got.UpdatedAt.Equal(updatedAt) {
|
|
t.Fatalf("projected report.md = %#v, want preserved created_at and updated updated_at", got)
|
|
}
|
|
merged, err := MergeOutputFiles(retained, projected)
|
|
if err != nil {
|
|
t.Fatalf("MergeOutputFiles() error = %v", err)
|
|
}
|
|
if got, want := len(merged), 3; got != want {
|
|
t.Fatalf("merged count = %d, want %d", got, want)
|
|
}
|
|
if got, want := ManagedOutputPaths(DistributorState{Outputs: merged}), []string{"old.md", "report.md", "new.md"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("managed paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func validStateJSON(t *testing.T) string {
|
|
t.Helper()
|
|
return validStateWithManifestJSON(t, manifestJSON(t))
|
|
}
|
|
|
|
func validStateWithManifestJSON(t *testing.T, manifest string) string {
|
|
t.Helper()
|
|
return `{
|
|
"schema_version": 2,
|
|
"distributor_version": "dev",
|
|
"pipeline_id": "reports",
|
|
"destination_id": "archive",
|
|
"published_at": "2026-05-30T11:12:00Z",
|
|
"created_at": "2026-05-30T11:12:00Z",
|
|
"updated_at": "2026-05-30T11:12:00Z",
|
|
"state": {"mode": "single_owner"},
|
|
"reconciliation": {"mode": "replace"},
|
|
"source": {
|
|
"manifest": ` + manifest + `
|
|
},
|
|
"outputs": [
|
|
{
|
|
"path": "report.md",
|
|
"kind": "source",
|
|
"source_path": "report.md",
|
|
"sha256": "sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6",
|
|
"size": 16,
|
|
"created_at": "2026-05-30T11:12:00Z",
|
|
"updated_at": "2026-05-30T11:12:00Z"
|
|
}
|
|
]
|
|
}`
|
|
}
|
|
|
|
func legacyStateJSON(t *testing.T) string {
|
|
t.Helper()
|
|
return strings.Replace(strings.Replace(strings.Replace(strings.Replace(strings.Replace(strings.Replace(validStateJSON(t),
|
|
`"schema_version": 2`, `"schema_version": 1`, 1),
|
|
` "created_at": "2026-05-30T11:12:00Z",
|
|
`, "", 1),
|
|
` "updated_at": "2026-05-30T11:12:00Z",
|
|
`, "", 1),
|
|
` "state": {"mode": "single_owner"},
|
|
`, "", 1),
|
|
` "reconciliation": {"mode": "replace"},
|
|
`, "", 1),
|
|
`,
|
|
"created_at": "2026-05-30T11:12:00Z",
|
|
"updated_at": "2026-05-30T11:12:00Z"`, "", 1)
|
|
}
|
|
|
|
func manifestJSON(t *testing.T) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile("../bundle/testdata/valid_bundle/manifest.json")
|
|
if err != nil {
|
|
t.Fatalf("read manifest fixture: %v", err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func validManifest(t *testing.T) bundle.Manifest {
|
|
t.Helper()
|
|
manifest, err := bundle.ParseManifest([]byte(manifestJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("ParseManifest() error = %v", err)
|
|
}
|
|
return manifest
|
|
}
|
|
|
|
func assertStateErrorContains(t *testing.T, err error, want string) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatalf("error = nil, want substring %q", want)
|
|
}
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("error = %q, want substring %q", err.Error(), want)
|
|
}
|
|
}
|