diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af7069..ba8c74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # Changelog +## Unreleased + +- Document and test optional CMS classification and relationships, including + unclassified publication and clearing associations with preserved history. + No runtime behavior or schema change. + ## v0.1.0-preview.27 — 2026-09-10 - Add independent `cms` values and a `cmssqlite` adapter for named taxonomies, diff --git a/TODO.md b/TODO.md index fea574e..313930e 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,16 @@ # Current work +September 10 consumer dogfood: reinforce that classification and relationships +are optional. Zero/nil/empty associations already work in `cms`/`cmssqlite`; new +regressions explicitly cover independent optional fields, publishing unclassified +content, and clearing classification without rewriting history. The reproduced +policy GET-modify-POST defect belongs to Gamertan's HTTP adapter, not the shared +package. Its fix stays in that consumer. No dependency upgrade or new package +release is needed for these documentation/test additions. +Verified: `go test -race ./cms ./cmssqlite` passes. Preserve unrelated auth work +in the separate `web-dev` checkout; it is not part of this slice. + ## CMS classification and relationships Implement `cms` and `cmssqlite` primitives for Gamertan's existing typed content: diff --git a/cms/cms.go b/cms/cms.go index 2ad069f..a578f15 100644 --- a/cms/cms.go +++ b/cms/cms.go @@ -45,6 +45,8 @@ func (r Reference) Validate() error { // Associations are an immutable revision's term memberships and explicit links. // Reverse discovery reads the same links; callers must not store a second edge. +// Terms and Links are independently optional. The zero value is valid for a +// valid source; unclassified content never needs a placeholder taxonomy or link. type Associations struct { Terms []string `json:"terms,omitempty"` Links []Reference `json:"links,omitempty"` diff --git a/cms/cms_test.go b/cms/cms_test.go index 61bffbb..ba6e335 100644 --- a/cms/cms_test.go +++ b/cms/cms_test.go @@ -24,6 +24,20 @@ func TestAssociationsBoundsAndIdentity(t *testing.T) { } } } + +func TestAssociationsAreOptional(t *testing.T) { + for _, source := range []Reference{{Kind: "news", ID: "article"}, {Kind: "project", ID: "project"}, {Kind: "policy", ID: "terms"}} { + for _, raw := range []string{`{}`, `{"terms":null,"links":null}`, `{"terms":[],"links":[]}`, `{"terms":["go"]}`, `{"links":[{"kind":"project","id":"other"}]}`} { + var value Associations + if err := json.Unmarshal([]byte(raw), &value); err != nil { + t.Fatal(err) + } + if err := value.Validate(source); err != nil { + t.Fatalf("optional associations rejected: %s: %v", raw, err) + } + } + } +} func TestTaxonomyAndTermText(t *testing.T) { tax := Taxonomy{ID: "categories", Slug: "categories", Name: "Categories", Revision: 1, Active: true} if tax.Validate() != nil { diff --git a/cmssqlite/store_test.go b/cmssqlite/store_test.go index ba2599f..91d5ca3 100644 --- a/cmssqlite/store_test.go +++ b/cmssqlite/store_test.go @@ -120,6 +120,31 @@ func TestPublicationRelationshipsAndHistory(t *testing.T) { mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutRevision(ctx, tx, "merchant", news, 1, cms.Associations{}) }) mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 999) }) } + +func TestPublishWithoutTaxonomyOrRelationships(t *testing.T) { + db, reader := fixture(t) + ctx := context.Background() + ref := cms.Reference{Kind: "project", ID: "unclassified"} + save(t, db, ref, 1, cms.Associations{}, true) + value, err := reader.Revision(ctx, ref, 1) + if err != nil || len(value.Terms) != 0 || len(value.Links) != 0 { + t.Fatalf("empty associations: %+v %v", value, err) + } + page, err := reader.Related(ctx, ref, nil, 10) + if err != nil || len(page.Items) != 0 { + t.Fatalf("unexpected related content: %+v %v", page, err) + } + save(t, db, ref, 2, cms.Associations{Terms: []string{"go"}}, true) + save(t, db, ref, 3, cms.Associations{}, true) + old, err := reader.Revision(ctx, ref, 2) + if err != nil || len(old.Terms) != 1 || old.Terms[0] != "go" { + t.Fatal("clearing optional classification rewrote history", err) + } + page, err = reader.Members(ctx, "go", nil, 10) + if err != nil || len(page.Items) != 0 { + t.Fatalf("cleared classification still published: %+v %v", page, err) + } +} func TestTermRenameRetirementAndScope(t *testing.T) { db, r := fixture(t) ctx := context.Background()