Files
distributor/internal/state/shared_root_test.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,
}},
}
}