362 lines
13 KiB
Go
362 lines
13 KiB
Go
package state
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestParseCatalogState(t *testing.T) {
|
|
state, err := ParseCatalog([]byte(validCatalogStateJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("ParseCatalog() error = %v", err)
|
|
}
|
|
if got, want := state.SchemaVersion, CatalogSchemaVersion; got != want {
|
|
t.Fatalf("schema version = %d, want %d", got, want)
|
|
}
|
|
if got, want := state.CreatedAtString(), "2026-06-19T12:00:00Z"; got != want {
|
|
t.Fatalf("created_at = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.UpdatedAtString(), "2026-06-19T12:05:00Z"; got != want {
|
|
t.Fatalf("updated_at = %q, want %q", got, want)
|
|
}
|
|
if got, want := state.State.Mode, StateModeCatalog; got != want {
|
|
t.Fatalf("state mode = %q, want %q", got, want)
|
|
}
|
|
if got, want := len(state.Outputs), 2; got != want {
|
|
t.Fatalf("output count = %d, want %d", got, want)
|
|
}
|
|
sourceOutput := state.Outputs[0]
|
|
if sourceOutput.SourcePath != "" || sourceOutput.Transform != "" {
|
|
t.Fatalf("source output source_path=%q transform=%q, want omitted", sourceOutput.SourcePath, sourceOutput.Transform)
|
|
}
|
|
generatedOutput := state.Outputs[1]
|
|
if generatedOutput.SourcePath != "report.md" || generatedOutput.Transform != "markdown_to_html" {
|
|
t.Fatalf("generated output source_path=%q transform=%q", generatedOutput.SourcePath, generatedOutput.Transform)
|
|
}
|
|
if got, want := generatedOutput.Source.CreatedString(), "2026-05-30T11:10:00Z"; got != want {
|
|
t.Fatalf("source created = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCatalogMarshalIsDeterministic(t *testing.T) {
|
|
data, err := json.Marshal(validCatalogState(t))
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
want := `{"schema_version":4,"distributor_version":"dev","created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z","state":{"mode":"catalog"},"outputs":[{"path":"report.md","pipeline_id":"reports","destination_id":"archive","source":{"id":"weather.daily.brentwood.2026-05-30","digest":"sha256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe","created":"2026-05-30T11:10:00Z"},"kind":"source","sha256":"sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6","size":16,"created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z"},{"path":"report.html","pipeline_id":"reports","destination_id":"html","source":{"id":"weather.daily.brentwood.2026-05-30","digest":"sha256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe","created":"2026-05-30T11:10:00Z"},"kind":"generated","source_path":"report.md","transform":"markdown_to_html","url":"https://reports.example.com/report.html","sha256":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":128,"created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z"}]}`
|
|
if string(data) != want {
|
|
t.Fatalf("json = %s, want %s", data, want)
|
|
}
|
|
}
|
|
|
|
func TestParseDocumentHandlesCatalogAndSupersededLegacy(t *testing.T) {
|
|
catalog, err := ParseDocument([]byte(validCatalogStateJSON(t)))
|
|
if err != nil {
|
|
t.Fatalf("ParseDocument(catalog) error = %v", err)
|
|
}
|
|
if catalog.Catalog == nil || catalog.SingleOwner != nil || catalog.SharedRoot != nil || catalog.SupersededLegacy != nil {
|
|
t.Fatalf("catalog document = %#v", catalog)
|
|
}
|
|
|
|
tests := map[string]string{
|
|
"schema 1": legacyStateJSON(t),
|
|
"schema 2": validStateJSON(t),
|
|
"schema 3": validSharedRootStateJSON(t),
|
|
}
|
|
for name, body := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
document, err := ParseDocument([]byte(body))
|
|
if err != nil {
|
|
t.Fatalf("ParseDocument() error = %v", err)
|
|
}
|
|
if document.SupersededLegacy == nil || document.Catalog != nil || document.SingleOwner != nil || document.SharedRoot != nil {
|
|
t.Fatalf("document = %#v, want superseded legacy only", document)
|
|
}
|
|
if document.SupersededLegacy.SchemaVersion < legacySchemaVersion || document.SupersededLegacy.SchemaVersion >= CatalogSchemaVersion {
|
|
t.Fatalf("legacy schema version = %d, want 1 through 3", document.SupersededLegacy.SchemaVersion)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseDocumentRejectsUnsupportedFutureSchema(t *testing.T) {
|
|
body := strings.Replace(validCatalogStateJSON(t), `"schema_version": 4`, `"schema_version": 5`, 1)
|
|
_, err := ParseDocument([]byte(body))
|
|
assertStateErrorContains(t, err, "schema_version 5 is unsupported")
|
|
}
|
|
|
|
func TestParseDocumentRejectsTrailingData(t *testing.T) {
|
|
_, err := ParseDocument([]byte(validCatalogStateJSON(t) + "\n{}"))
|
|
assertStateErrorContains(t, err, "trailing data")
|
|
}
|
|
|
|
func TestParseCatalogRejectsMissingFields(t *testing.T) {
|
|
tests := map[string]func(map[string]any){
|
|
"schema_version": func(document map[string]any) {
|
|
delete(document, "schema_version")
|
|
},
|
|
"created_at": func(document map[string]any) {
|
|
delete(document, "created_at")
|
|
},
|
|
"updated_at": func(document map[string]any) {
|
|
delete(document, "updated_at")
|
|
},
|
|
"state": func(document map[string]any) {
|
|
delete(document, "state")
|
|
},
|
|
"outputs": func(document map[string]any) {
|
|
delete(document, "outputs")
|
|
},
|
|
"path": func(document map[string]any) {
|
|
delete(firstCatalogOutput(document), "path")
|
|
},
|
|
"pipeline_id": func(document map[string]any) {
|
|
delete(firstCatalogOutput(document), "pipeline_id")
|
|
},
|
|
"destination_id": func(document map[string]any) {
|
|
delete(firstCatalogOutput(document), "destination_id")
|
|
},
|
|
"source": func(document map[string]any) {
|
|
delete(firstCatalogOutput(document), "source")
|
|
},
|
|
"source id": func(document map[string]any) {
|
|
delete(firstCatalogSource(document), "id")
|
|
},
|
|
"source digest": func(document map[string]any) {
|
|
delete(firstCatalogSource(document), "digest")
|
|
},
|
|
"source created": func(document map[string]any) {
|
|
delete(firstCatalogSource(document), "created")
|
|
},
|
|
"kind": func(document map[string]any) {
|
|
delete(firstCatalogOutput(document), "kind")
|
|
},
|
|
"sha256": func(document map[string]any) {
|
|
delete(firstCatalogOutput(document), "sha256")
|
|
},
|
|
"size": func(document map[string]any) {
|
|
delete(firstCatalogOutput(document), "size")
|
|
},
|
|
}
|
|
for name, mutate := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
document := catalogStateObject(t)
|
|
mutate(document)
|
|
_, err := ParseCatalog(mustMarshalCatalogObject(t, document))
|
|
assertStateErrorContains(t, err, "required")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCatalogRejectsMalformedTimestamps(t *testing.T) {
|
|
tests := map[string]func(string) string{
|
|
"created_at": func(body string) string {
|
|
return strings.Replace(body, `"created_at": "2026-06-19T12:00:00Z"`, `"created_at": "June 19"`, 1)
|
|
},
|
|
"updated_at": func(body string) string {
|
|
return strings.Replace(body, `"updated_at": "2026-06-19T12:05:00Z"`, `"updated_at": "June 19"`, 1)
|
|
},
|
|
"source created": func(body string) string {
|
|
return strings.Replace(body, `"created": "2026-05-30T11:10:00Z"`, `"created": "May 30"`, 1)
|
|
},
|
|
"output created_at": func(body string) string {
|
|
return strings.Replace(body, ` "created_at": "2026-06-19T12:00:00Z"`, ` "created_at": "June 19"`, 1)
|
|
},
|
|
"output updated_at": func(body string) string {
|
|
return strings.Replace(body, ` "updated_at": "2026-06-19T12:05:00Z"`, ` "updated_at": "June 19"`, 1)
|
|
},
|
|
}
|
|
for name, mutate := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := ParseCatalog([]byte(mutate(validCatalogStateJSON(t))))
|
|
assertStateErrorContains(t, err, "RFC3339")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateCatalogRejectsInvalidOutputRecords(t *testing.T) {
|
|
tests := map[string]func(*CatalogState){
|
|
"duplicate path": func(s *CatalogState) {
|
|
s.Outputs[1].Path = s.Outputs[0].Path
|
|
},
|
|
"invalid output path": func(s *CatalogState) {
|
|
s.Outputs[0].Path = "../report.md"
|
|
},
|
|
"invalid pipeline id": func(s *CatalogState) {
|
|
s.Outputs[0].PipelineID = ".reports"
|
|
},
|
|
"invalid destination id": func(s *CatalogState) {
|
|
s.Outputs[0].DestinationID = ".archive"
|
|
},
|
|
"missing source id": func(s *CatalogState) {
|
|
s.Outputs[0].Source.ID = ""
|
|
},
|
|
"invalid source digest": func(s *CatalogState) {
|
|
s.Outputs[0].Source.Digest = "SHA256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe"
|
|
},
|
|
"missing source created": func(s *CatalogState) {
|
|
s.Outputs[0].Source.Created = time.Time{}
|
|
},
|
|
"invalid kind": func(s *CatalogState) {
|
|
s.Outputs[0].Kind = "document"
|
|
},
|
|
"generated missing source path": func(s *CatalogState) {
|
|
s.Outputs[1].SourcePath = ""
|
|
},
|
|
"generated invalid source path": func(s *CatalogState) {
|
|
s.Outputs[1].SourcePath = "../report.md"
|
|
},
|
|
"generated missing transform": func(s *CatalogState) {
|
|
s.Outputs[1].Transform = ""
|
|
},
|
|
"source output source path": func(s *CatalogState) {
|
|
s.Outputs[0].SourcePath = "report.md"
|
|
},
|
|
"source output transform": func(s *CatalogState) {
|
|
s.Outputs[0].Transform = "markdown_to_html"
|
|
},
|
|
"invalid output digest": func(s *CatalogState) {
|
|
s.Outputs[0].SHA256 = "SHA256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6"
|
|
},
|
|
"negative size": func(s *CatalogState) {
|
|
s.Outputs[0].Size = -1
|
|
},
|
|
"invalid url": func(s *CatalogState) {
|
|
s.Outputs[1].URL = "file:///tmp/report.html"
|
|
},
|
|
"missing created at": func(s *CatalogState) {
|
|
s.Outputs[0].CreatedAt = time.Time{}
|
|
},
|
|
"missing updated at": func(s *CatalogState) {
|
|
s.Outputs[0].UpdatedAt = time.Time{}
|
|
},
|
|
}
|
|
for name, mutate := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
state := validCatalogState(t)
|
|
mutate(&state)
|
|
if err := ValidateCatalog(state); err == nil {
|
|
t.Fatal("ValidateCatalog() error = nil, want error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCatalogRejectsForbiddenFields(t *testing.T) {
|
|
tests := map[string]string{
|
|
"owners": `"owners": [],`,
|
|
"sources": `"sources": [],`,
|
|
"workflow": `"workflow": "additive",`,
|
|
"source": `"source": {"manifest": {}},`,
|
|
"manifest": `"manifest": {},`,
|
|
"pipeline_id": `"pipeline_id": "reports",`,
|
|
"destination_id": `"destination_id": "archive",`,
|
|
"published_at": `"published_at": "2026-06-19T12:00:00Z",`,
|
|
}
|
|
for name, field := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
body := strings.Replace(validCatalogStateJSON(t), `"created_at":`, field+"\n "+`"created_at":`, 1)
|
|
_, err := ParseCatalog([]byte(body))
|
|
assertStateErrorContains(t, err, "unknown field")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseCatalogRejectsForbiddenOutputFieldsForSourceOutput(t *testing.T) {
|
|
tests := map[string]string{
|
|
"source_path": `"source_path": "report.md",`,
|
|
"transform": `"transform": "markdown_to_html",`,
|
|
"empty url": `"url": "",`,
|
|
}
|
|
for name, field := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
body := strings.Replace(validCatalogStateJSON(t), `"kind": "source",`, `"kind": "source",`+"\n "+field, 1)
|
|
_, err := ParseCatalog([]byte(body))
|
|
if err == nil {
|
|
t.Fatal("ParseCatalog() error = nil, want error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func validCatalogStateJSON(t *testing.T) string {
|
|
t.Helper()
|
|
data, err := json.MarshalIndent(validCatalogState(t), "", " ")
|
|
if err != nil {
|
|
t.Fatalf("marshal catalog state: %v", err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func catalogStateObject(t *testing.T) map[string]any {
|
|
t.Helper()
|
|
var document map[string]any
|
|
if err := json.Unmarshal([]byte(validCatalogStateJSON(t)), &document); err != nil {
|
|
t.Fatalf("unmarshal catalog state: %v", err)
|
|
}
|
|
return document
|
|
}
|
|
|
|
func firstCatalogOutput(document map[string]any) map[string]any {
|
|
outputs := document["outputs"].([]any)
|
|
return outputs[0].(map[string]any)
|
|
}
|
|
|
|
func firstCatalogSource(document map[string]any) map[string]any {
|
|
return firstCatalogOutput(document)["source"].(map[string]any)
|
|
}
|
|
|
|
func mustMarshalCatalogObject(t *testing.T, document map[string]any) []byte {
|
|
t.Helper()
|
|
data, err := json.Marshal(document)
|
|
if err != nil {
|
|
t.Fatalf("marshal catalog object: %v", err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func validCatalogState(t *testing.T) CatalogState {
|
|
t.Helper()
|
|
manifest := validManifest(t)
|
|
createdAt := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
|
|
updatedAt := time.Date(2026, 6, 19, 12, 5, 0, 0, time.UTC)
|
|
source := CatalogSourceIdentity{
|
|
ID: manifest.ID,
|
|
Digest: manifest.Digest,
|
|
Created: manifest.Created,
|
|
}
|
|
return CatalogState{
|
|
SchemaVersion: CatalogSchemaVersion,
|
|
DistributorVersion: "dev",
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
State: StatePolicy{Mode: StateModeCatalog},
|
|
Outputs: []CatalogOutputFile{{
|
|
Path: "report.md",
|
|
PipelineID: "reports",
|
|
DestinationID: "archive",
|
|
Source: source,
|
|
Kind: OutputKindSource,
|
|
SHA256: manifest.Files[0].SHA256,
|
|
Size: manifest.Files[0].Size,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
}, {
|
|
Path: "report.html",
|
|
PipelineID: "reports",
|
|
DestinationID: "html",
|
|
Source: source,
|
|
Kind: OutputKindGenerated,
|
|
SourcePath: "report.md",
|
|
Transform: "markdown_to_html",
|
|
URL: "https://reports.example.com/report.html",
|
|
SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
Size: 128,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
}},
|
|
}
|
|
}
|