333 lines
12 KiB
Go
333 lines
12 KiB
Go
package state
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
|
)
|
|
|
|
func TestParseDocumentHandlesSingleOwnerAndSharedRoot(t *testing.T) {
|
|
singleOwner, err := ParseDocument([]byte(validStateJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("ParseDocument(single owner) error = %v", err)
|
|
}
|
|
if singleOwner.SingleOwner == nil || singleOwner.SharedRoot != nil {
|
|
t.Fatalf("single owner document = %#v", singleOwner)
|
|
}
|
|
|
|
sharedRoot, err := ParseDocument([]byte(validSharedRootStateJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("ParseDocument(shared root) error = %v", err)
|
|
}
|
|
if sharedRoot.SharedRoot == nil || sharedRoot.SingleOwner != nil {
|
|
t.Fatalf("shared root document = %#v", sharedRoot)
|
|
}
|
|
}
|
|
|
|
func TestParseSharedRootState(t *testing.T) {
|
|
state, err := ParseSharedRoot([]byte(validSharedRootStateJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("ParseSharedRoot() error = %v", err)
|
|
}
|
|
if got, want := state.SchemaVersion, SharedRootSchemaVersion; got != want {
|
|
t.Fatalf("schema version = %d, want %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, StateModeSharedRoot; got != want {
|
|
t.Fatalf("state mode = %q, want %q", got, want)
|
|
}
|
|
if got, want := len(state.Owners), 2; got != want {
|
|
t.Fatalf("owner count = %d, want %d", got, want)
|
|
}
|
|
if got, want := len(state.Outputs), 2; got != want {
|
|
t.Fatalf("output count = %d, want %d", got, want)
|
|
}
|
|
scope := CurrentOwnerScope("reports", "archive")
|
|
manifest, ok := state.SourceManifest(scope)
|
|
if !ok {
|
|
t.Fatal("SourceManifest() ok = false, want true")
|
|
}
|
|
if got, want := manifest.ID, validManifest(t).ID; got != want {
|
|
t.Fatalf("source manifest id = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseSharedRootRejectsInvalidMetadata(t *testing.T) {
|
|
tests := map[string]func(string) string{
|
|
"schema": func(body string) string {
|
|
return strings.Replace(body, `"schema_version": 3`, `"schema_version": 2`, 1)
|
|
},
|
|
"state mode": func(body string) string {
|
|
return strings.Replace(body, `"mode": "shared_root"`, `"mode": "single_owner"`, 1)
|
|
},
|
|
"duplicate owner": func(body string) string {
|
|
return strings.Replace(body, `"destination_id": "html"`, `"destination_id": "archive"`, 1)
|
|
},
|
|
"unknown output owner": func(body string) string {
|
|
return strings.Replace(body, `"destination_id": "html",`, `"destination_id": "missing",`, 1)
|
|
},
|
|
"duplicate output": func(body string) string {
|
|
return strings.Replace(body, `"path": "report.html"`, `"path": "report.md"`, 1)
|
|
},
|
|
"invalid source digest": func(body string) string {
|
|
return strings.Replace(body, `"source_digest": "sha256:`, `"source_digest": "SHA256:`, 1)
|
|
},
|
|
"invalid owner link": func(body string) string {
|
|
return strings.Replace(body, `"primary_url": "https://reports.example.com/archive/report.md"`, `"primary_url": "file:///tmp/report.md"`, 1)
|
|
},
|
|
}
|
|
for name, mutate := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := ParseSharedRoot([]byte(mutate(validSharedRootStateJSON(t))))
|
|
if err == nil {
|
|
t.Fatal("ParseSharedRoot() error = nil, want error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSharedRootMarshalNormalizesTimestamps(t *testing.T) {
|
|
state := validSharedRootState(t)
|
|
state.CreatedAt = time.Date(2026, 5, 30, 13, 12, 0, 0, time.FixedZone("offset", 2*60*60))
|
|
state.UpdatedAt = state.CreatedAt
|
|
state.Outputs[0].SourceCreated = state.CreatedAt
|
|
state.Outputs[0].CreatedAt = state.CreatedAt
|
|
state.Outputs[0].UpdatedAt = state.CreatedAt
|
|
|
|
data, err := json.Marshal(state)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
`"created_at":"2026-05-30T11:12:00Z"`,
|
|
`"updated_at":"2026-05-30T11:12:00Z"`,
|
|
`"source_created":"2026-05-30T11:12:00Z"`,
|
|
} {
|
|
if !strings.Contains(string(data), want) {
|
|
t.Fatalf("json = %s, want %s", data, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSharedRootOutputHelpers(t *testing.T) {
|
|
state := validSharedRootState(t)
|
|
archive := CurrentOwnerScope("reports", "archive")
|
|
html := CurrentOwnerScope("reports", "html")
|
|
|
|
if got, want := state.ManagedOutputPaths(archive), []string{"report.md"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("archive paths = %#v, want %#v", got, want)
|
|
}
|
|
if got, want := state.AllManagedOutputPaths(), []string{"report.md", "report.html"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("all paths = %#v, want %#v", got, want)
|
|
}
|
|
conflict, ok := state.PathOwnershipConflict(archive, []string{"report.html"})
|
|
if !ok || conflict.Owner != html {
|
|
t.Fatalf("conflict = %#v ok=%t, want html owner conflict", conflict, ok)
|
|
}
|
|
if conflict.Detail.Kind != ComparisonDetailSharedRootOutputOwner {
|
|
t.Fatalf("conflict detail kind = %q, want %q", conflict.Detail.Kind, ComparisonDetailSharedRootOutputOwner)
|
|
}
|
|
if conflict.Detail.Path != "report.html" || conflict.Detail.CurrentOwner != archive || conflict.Detail.ConflictingOwner != html {
|
|
t.Fatalf("conflict detail = %#v, want path and owners", conflict.Detail)
|
|
}
|
|
}
|
|
|
|
func TestRemoveMissingSharedRootOwnerOutputs(t *testing.T) {
|
|
state := validSharedRootState(t)
|
|
archive := CurrentOwnerScope("reports", "archive")
|
|
next, changed := RemoveMissingSharedRootOwnerOutputs(state, archive, []string{"report.md", "report.html"})
|
|
if !changed {
|
|
t.Fatal("RemoveMissingSharedRootOwnerOutputs() changed = false, want true")
|
|
}
|
|
if got, want := next.AllManagedOutputPaths(), []string{"report.html"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("paths = %#v, want %#v", got, want)
|
|
}
|
|
if _, ok := next.OutputOwner("report.html"); !ok {
|
|
t.Fatal("report.html owner missing, want unrelated owner preserved")
|
|
}
|
|
}
|
|
|
|
func TestRemoveMissingSharedRootOutputs(t *testing.T) {
|
|
state := validSharedRootState(t)
|
|
next, changed := RemoveMissingSharedRootOutputs(state, []string{"report.md", "report.html"})
|
|
if !changed {
|
|
t.Fatal("RemoveMissingSharedRootOutputs() changed = false, want true")
|
|
}
|
|
if got := next.AllManagedOutputPaths(); len(got) != 0 {
|
|
t.Fatalf("paths = %#v, want none", got)
|
|
}
|
|
}
|
|
|
|
func TestSharedRootProjectAndMergeOwnerOutputs(t *testing.T) {
|
|
state := validSharedRootState(t)
|
|
archive := CurrentOwnerScope("reports", "archive")
|
|
owner, ok := state.Owner(archive)
|
|
if !ok {
|
|
t.Fatal("Owner() ok = false, want true")
|
|
}
|
|
now := time.Date(2026, 5, 30, 12, 30, 0, 0, time.UTC)
|
|
planned := ProjectSharedRootOutputs([]OutputProjection{{
|
|
Path: "report.md",
|
|
Kind: OutputKindSource,
|
|
SourcePath: "report.md",
|
|
SHA256: validManifest(t).Files[0].SHA256,
|
|
Size: validManifest(t).Files[0].Size,
|
|
}, {
|
|
Path: "summary.txt",
|
|
Kind: OutputKindSource,
|
|
SourcePath: "summary.txt",
|
|
SHA256: validManifest(t).Files[1].SHA256,
|
|
Size: validManifest(t).Files[1].Size,
|
|
}}, state.Outputs, archive, validManifest(t), now)
|
|
|
|
merged, err := MergeOwnerOutputs(state, archive, owner, planned)
|
|
if err != nil {
|
|
t.Fatalf("MergeOwnerOutputs() error = %v", err)
|
|
}
|
|
if got, want := merged.AllManagedOutputPaths(), []string{"report.md", "report.html", "summary.txt"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("merged paths = %#v, want %#v", got, want)
|
|
}
|
|
if merged.Outputs[0].CreatedAt.Equal(now) {
|
|
t.Fatalf("merged updated output created_at = %s, want preserved timestamp", merged.Outputs[0].CreatedAt)
|
|
}
|
|
if !merged.Outputs[0].UpdatedAt.Equal(now) {
|
|
t.Fatalf("merged updated output updated_at = %s, want %s", merged.Outputs[0].UpdatedAt, now)
|
|
}
|
|
|
|
replaced, err := ReplaceOwnerOutputs(state, archive, owner, planned)
|
|
if err != nil {
|
|
t.Fatalf("ReplaceOwnerOutputs() error = %v", err)
|
|
}
|
|
if got, want := replaced.AllManagedOutputPaths(), []string{"report.html", "report.md", "summary.txt"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("replaced paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSharedRootOwnerOutputHelpersRejectConflicts(t *testing.T) {
|
|
state := validSharedRootState(t)
|
|
archive := CurrentOwnerScope("reports", "archive")
|
|
owner, ok := state.Owner(archive)
|
|
if !ok {
|
|
t.Fatal("Owner() ok = false, want true")
|
|
}
|
|
planned := []SharedRootOutputFile{{
|
|
Path: "report.html",
|
|
Kind: OutputKindSource,
|
|
Owner: archive,
|
|
CreatedAt: time.Date(2026, 5, 30, 12, 30, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2026, 5, 30, 12, 30, 0, 0, time.UTC),
|
|
}}
|
|
|
|
if _, err := MergeOwnerOutputs(state, archive, owner, planned); err == nil {
|
|
t.Fatal("MergeOwnerOutputs() error = nil, want owner conflict")
|
|
}
|
|
if _, err := ReplaceOwnerOutputs(state, archive, owner, planned); err == nil {
|
|
t.Fatal("ReplaceOwnerOutputs() error = nil, want owner conflict")
|
|
}
|
|
}
|
|
|
|
func TestCompareSharedRootOwnerScopesCurrentOwner(t *testing.T) {
|
|
state := validSharedRootState(t)
|
|
source := validManifest(t)
|
|
source.Created = source.Created.Add(time.Hour)
|
|
scope := CurrentOwnerScope("reports", "archive")
|
|
|
|
comparison := CompareSharedRootOwner(source, scope, DestinationStatus{SharedRoot: &state, HasContents: true})
|
|
if comparison.Outcome != OutcomeDestinationOlder {
|
|
t.Fatalf("comparison = %#v, want destination older for current owner", comparison)
|
|
}
|
|
|
|
missing := CompareSharedRootOwner(source, CurrentOwnerScope("missing", "archive"), DestinationStatus{SharedRoot: &state, HasContents: true})
|
|
if missing.Outcome != OutcomeDestinationAbsent {
|
|
t.Fatalf("missing owner comparison = %#v, want destination absent", missing)
|
|
}
|
|
if missing.Detail.Kind != ComparisonDetailSharedRootOwnerAbsent {
|
|
t.Fatalf("missing owner detail kind = %q, want %q", missing.Detail.Kind, ComparisonDetailSharedRootOwnerAbsent)
|
|
}
|
|
if missing.Detail.CurrentOwner != CurrentOwnerScope("missing", "archive") {
|
|
t.Fatalf("missing owner detail = %#v, want missing/archive", missing.Detail)
|
|
}
|
|
}
|
|
|
|
func TestCompareSharedRootOwnerAcceptsMatchingSingleOwnerState(t *testing.T) {
|
|
singleOwner, err := Parse([]byte(validStateJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("Parse() error = %v", err)
|
|
}
|
|
source := singleOwner.Source.Manifest
|
|
source.Created = source.Created.Add(time.Hour)
|
|
scope := CurrentOwnerScope(singleOwner.PipelineID, singleOwner.DestinationID)
|
|
|
|
comparison := CompareSharedRootOwner(source, scope, DestinationStatus{State: &singleOwner, HasContents: true})
|
|
if comparison.Outcome != OutcomeDestinationOlder {
|
|
t.Fatalf("comparison = %#v, want destination older", comparison)
|
|
}
|
|
}
|
|
|
|
func validSharedRootStateJSON(t *testing.T) string {
|
|
t.Helper()
|
|
data, err := json.MarshalIndent(validSharedRootState(t), "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal shared root state: %v", err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func validSharedRootState(t *testing.T) SharedRootState {
|
|
t.Helper()
|
|
source := validManifest(t)
|
|
htmlSource := source
|
|
htmlSource.Files = append([]bundle.ManifestFile(nil), source.Files...)
|
|
createdAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC)
|
|
return SharedRootState{
|
|
SchemaVersion: SharedRootSchemaVersion,
|
|
DistributorVersion: "dev",
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
State: StatePolicy{Mode: StateModeSharedRoot},
|
|
Owners: []OwnerRecord{{
|
|
Scope: CurrentOwnerScope("reports", "archive"),
|
|
Reconciliation: ReconciliationPolicy{Mode: config.ReconciliationModeReplace},
|
|
Source: SourceState{Manifest: source},
|
|
Links: &LinkState{PrimaryURL: "https://reports.example.com/archive/report.md"},
|
|
}, {
|
|
Scope: CurrentOwnerScope("reports", "html"),
|
|
Reconciliation: ReconciliationPolicy{Mode: config.ReconciliationModeMerge},
|
|
Source: SourceState{Manifest: htmlSource},
|
|
}},
|
|
Outputs: []SharedRootOutputFile{{
|
|
Path: "report.md",
|
|
Kind: OutputKindSource,
|
|
SourcePath: "report.md",
|
|
SHA256: source.Files[0].SHA256,
|
|
Size: source.Files[0].Size,
|
|
Owner: CurrentOwnerScope("reports", "archive"),
|
|
SourceID: source.ID,
|
|
SourceDigest: source.Digest,
|
|
SourceCreated: source.Created,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
}, {
|
|
Path: "report.html",
|
|
Kind: OutputKindGenerated,
|
|
SourcePath: "report.md",
|
|
Transform: "markdown_to_html",
|
|
URL: "https://reports.example.com/html/report.html",
|
|
SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
Size: 128,
|
|
Owner: CurrentOwnerScope("reports", "html"),
|
|
SourceID: htmlSource.ID,
|
|
SourceDigest: htmlSource.Digest,
|
|
SourceCreated: htmlSource.Created,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
}},
|
|
}
|
|
}
|