// SPDX-License-Identifier: MPL-2.0 package cmssqlite import ( "context" "database/sql" "errors" "fmt" "path/filepath" "testing" "gamertan.com/web/cms" _ "modernc.org/sqlite" ) func fixture(t *testing.T) (*sql.DB, *Reader) { t.Helper() db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "cms.sqlite")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate") if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) mutate(t, db, nil, func(tx *sql.Tx) error { return CreateSchema(context.Background(), tx) }) r, e := New(db, "merchant") if e != nil { t.Fatal(e) } for _, scope := range []string{"merchant", "other"} { mutate(t, db, nil, func(tx *sql.Tx) error { return PutTaxonomy(context.Background(), tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Active: true, Revision: 1}, 0) }) } mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(context.Background(), tx, "merchant", cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Active: true, Revision: 1}, 0) }) return db, r } func mutate(t *testing.T, db *sql.DB, want error, fn func(*sql.Tx) error) { t.Helper() tx, e := db.BeginTx(context.Background(), nil) if e != nil { t.Fatal(e) } defer tx.Rollback() err := fn(tx) if !errors.Is(err, want) { t.Fatalf("mutation error %v, want %v", err, want) } if err == nil { if e = tx.Commit(); e != nil { t.Fatal(e) } } } func save(t *testing.T, db *sql.DB, ref cms.Reference, rev int64, a cms.Associations, publish bool) { t.Helper() mutate(t, db, nil, func(tx *sql.Tx) error { if err := PutRevision(context.Background(), tx, "merchant", ref, rev, a); err != nil { return err } if publish { return SetPublished(context.Background(), tx, "merchant", ref, rev) } return nil }) } func TestPublicationRelationshipsAndHistory(t *testing.T) { db, r := fixture(t) ctx := context.Background() project := cms.Reference{Kind: "project", ID: "hime"} news := cms.Reference{Kind: "news", ID: "launch"} product := cms.Reference{Kind: "product", ID: "support"} save(t, db, project, 1, cms.Associations{Terms: []string{"go"}}, true) save(t, db, news, 1, cms.Associations{Terms: []string{"go"}, Links: []cms.Reference{project}}, false) p, e := r.Related(ctx, project, nil, 10) if e != nil || len(p.Items) != 0 { t.Fatalf("draft leak: %+v %v", p, e) } mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 1) }) for _, ref := range []cms.Reference{project, news} { p, e = r.Related(ctx, ref, nil, 10) if e != nil || len(p.Items) != 1 { t.Fatalf("reverse missing: %+v %v", p, e) } } save(t, db, product, 1, cms.Associations{}, true) save(t, db, news, 2, cms.Associations{Links: []cms.Reference{product}}, false) p, e = r.Related(ctx, project, nil, 10) if e != nil || len(p.Items) != 1 { t.Fatal("draft replaced published graph", e) } mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 2) }) p, e = r.Related(ctx, project, nil, 10) if e != nil || len(p.Items) != 0 { t.Fatal("stale published edge", e) } p, e = r.Related(ctx, product, nil, 10) if e != nil || len(p.Items) != 1 { t.Fatal("missing new edge", e) } old, e := r.Revision(ctx, news, 1) if e != nil || old.Links[0] != project { t.Fatal("history changed", e) } mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", product, 0) }) p, e = r.Related(ctx, news, nil, 10) if e != nil || len(p.Items) != 0 { t.Fatal("unpublished target leak", e) } p, e = r.Related(ctx, product, nil, 10) if e != nil || len(p.Items) != 0 { t.Fatal("unpublished source discovery", e) } // Restoring an old association is a new revision, not an overwritten row. save(t, db, news, 3, old, true) p, e = r.Related(ctx, project, nil, 10) if e != nil || len(p.Items) != 1 || p.Items[0].Revision != 3 { t.Fatal("restore", e) } 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 TestTermRenameRetirementAndScope(t *testing.T) { db, r := fixture(t) ctx := context.Background() ref := cms.Reference{Kind: "writing", ID: "essay"} save(t, db, ref, 1, cms.Associations{Terms: []string{"go"}}, true) term, e := r.Term(ctx, "go") if e != nil { t.Fatal(e) } term.Slug = "golang" term.Name = "Go language" term.Revision = 2 mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 1) }) alias, e := r.TermBySlug(ctx, "topics", "go") if e != nil || alias.ID != term.ID || alias.Slug != "golang" { t.Fatal("alias", e) } stolen := cms.Term{ID: "stolen", TaxonomyID: "topics", Slug: "go", Name: "Other", Revision: 1, Active: true} mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", stolen, 0) }) duplicate := term duplicate.Revision = 1 mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", duplicate, 0) }) other, _ := New(db, "other") if _, e = other.Term(ctx, "go"); !errors.Is(e, cms.ErrNotFound) { t.Fatal("cross scope term", e) } mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error { return PutRevision(ctx, tx, "other", ref, 1, cms.Associations{Terms: []string{"go"}}) }) p, e := other.Members(ctx, "go", nil, 10) if e != nil || len(p.Items) != 0 { t.Fatal("scope leak", e) } p, e = r.Members(ctx, "go", nil, 10) if e != nil || len(p.Items) != 1 { t.Fatal("membership lost on rename", e) } term.Active = false term.Revision = 3 mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 2) }) p, e = r.Members(ctx, "go", nil, 10) if e != nil || len(p.Items) != 0 { t.Fatal("retired term discovery", e) } old, e := r.Revision(ctx, ref, 1) if e != nil || len(old.Terms) != 1 { t.Fatal("retirement rewrote history", e) } save(t, db, ref, 2, old, true) } func TestPaginationFiltersDraftsBeforeLimitAndRollback(t *testing.T) { db, r := fixture(t) ctx := context.Background() for i := 0; i < 35; i++ { save(t, db, cms.Reference{Kind: "news", ID: fmt.Sprintf("news-%02d", i)}, 1, cms.Associations{Terms: []string{"go"}}, i >= 30) } var after *cms.Reference var ids []string for { p, e := r.Members(ctx, "go", after, 2) if e != nil { t.Fatal(e) } for _, v := range p.Items { ids = append(ids, v.ID) } if p.Next == nil { break } after = p.Next } if len(ids) != 5 || ids[0] != "news-30" || ids[4] != "news-34" { t.Fatal(ids) } tx, e := db.BeginTx(ctx, nil) if e != nil { t.Fatal(e) } ref := cms.Reference{Kind: "project", ID: "rollback"} if e = PutRevision(ctx, tx, "merchant", ref, 1, cms.Associations{}); e != nil { t.Fatal(e) } if e = SetPublished(ctx, tx, "merchant", ref, 1); e != nil { t.Fatal(e) } if e = tx.Rollback(); e != nil { t.Fatal(e) } if _, e = r.PublishedRevision(ctx, ref); !errors.Is(e, cms.ErrNotFound) { t.Fatal("partial transaction", e) } }