This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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
|
## v0.1.0-preview.27 — 2026-09-10
|
||||||
|
|
||||||
- Add independent `cms` values and a `cmssqlite` adapter for named taxonomies,
|
- Add independent `cms` values and a `cmssqlite` adapter for named taxonomies,
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
# Current work
|
# 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
|
## CMS classification and relationships
|
||||||
|
|
||||||
Implement `cms` and `cmssqlite` primitives for Gamertan's existing typed content:
|
Implement `cms` and `cmssqlite` primitives for Gamertan's existing typed content:
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ func (r Reference) Validate() error {
|
|||||||
|
|
||||||
// Associations are an immutable revision's term memberships and explicit links.
|
// Associations are an immutable revision's term memberships and explicit links.
|
||||||
// Reverse discovery reads the same links; callers must not store a second edge.
|
// 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 {
|
type Associations struct {
|
||||||
Terms []string `json:"terms,omitempty"`
|
Terms []string `json:"terms,omitempty"`
|
||||||
Links []Reference `json:"links,omitempty"`
|
Links []Reference `json:"links,omitempty"`
|
||||||
|
|||||||
@@ -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) {
|
func TestTaxonomyAndTermText(t *testing.T) {
|
||||||
tax := Taxonomy{ID: "categories", Slug: "categories", Name: "Categories", Revision: 1, Active: true}
|
tax := Taxonomy{ID: "categories", Slug: "categories", Name: "Categories", Revision: 1, Active: true}
|
||||||
if tax.Validate() != nil {
|
if tax.Validate() != nil {
|
||||||
|
|||||||
@@ -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.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) })
|
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) {
|
func TestTermRenameRetirementAndScope(t *testing.T) {
|
||||||
db, r := fixture(t)
|
db, r := fixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
Reference in New Issue
Block a user