test(12.2-05): cover every Go behaviour of phase 12.2

- lagoon: Date and TimeOfDay through JSON, text and real DATE/TIME
  columns; Fill text fallback without changing earlier conversions;
  required on zero dates; deferred_bindings shape, store isolation and
  envelope; PurgeDeferred cut-off, after-commit blobs, SKIP LOCKED,
  skipped types and the deferred:purge command
- attach: Store limits, extensions, MIME patterns, default lists, key
  shape and blob cleanup; IsAllowedImage formats, polyglots, ceiling
- conga and pact: framework purge schedule entry and forged jobs; the
  six relation child hook interfaces
- cabana: fileupload and datepicker compile, upload, remove, caption,
  reorder and bounds; deferred commit order, rollback, applied-only and
  concurrent saves; relation contracts, forms, CRUD, deferral, schema
This commit is contained in:
Jakub Zych
2026-10-02 20:34:23 +02:00
parent 9c87a59532
commit c8d41a68c6
16 changed files with 3509 additions and 10 deletions

View File

@@ -14,6 +14,7 @@ import (
"path/filepath"
"sync"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
@@ -59,11 +60,13 @@ type dfGadget struct {
Hidden bool `gorm:"column:hidden;not null;default:false"`
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
StartsAt *time.Time `gorm:"column:starts_at"`
OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time"`
OpensAt lagoon.TimeOfDay `gorm:"column:opens_at;type:time without time zone"`
Members []dfMember `gorm:"-"`
Parts []dfPart `gorm:"-"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
// StrictParts backs a test-only hasMany on the NOT NULL dfStrictPart.
StrictParts []dfStrictPart `gorm:"-"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (dfGadget) TableName() string { return "cabana_deferred_gadgets" }
@@ -183,13 +186,24 @@ func (r *dfRecorder) hook(ctx context.Context, name string) error {
return nil
}
type dfPlugin struct{ rec *dfRecorder }
// dfPlugin is the fixture plugin. The zero options give the testdata tree,
// the gadgets and locked controllers and the full Models() list; a test
// may replace the tree (dfOverlay), the controllers or the models.
type dfPlugin struct {
rec *dfRecorder
fsys fs.FS
controllers func(rec *dfRecorder) []pact.AdminController
noModels bool
}
func (dfPlugin) ID() string { return "acme.deferred" }
func (dfPlugin) Requires() []string { return nil }
func (dfPlugin) Register(*backpack.App) error { return nil }
func (dfPlugin) Boot(*backpack.App) error { return nil }
func (p dfPlugin) AdminControllers() []pact.AdminController {
if p.controllers != nil {
return p.controllers(p.rec)
}
return []pact.AdminController{
dfController{rec: p.rec, id: "acme.deferred.gadgets", dir: "controllers/gadgets"},
dfController{rec: p.rec, id: "acme.deferred.locked", dir: "controllers/locked"},
@@ -197,7 +211,12 @@ func (p dfPlugin) AdminControllers() []pact.AdminController {
}
// Models lists every fixture model deferred:purge may delete.
func (dfPlugin) Models() []any { return []any{&dfGadget{}, &dfPart{}, &dfMember{}} }
func (p dfPlugin) Models() []any {
if p.noModels {
return nil
}
return []any{&dfGadget{}, &dfPart{}, &dfMember{}}
}
func (dfPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.deferred.access", Roles: []string{"developer"}}}
@@ -207,13 +226,84 @@ func (dfPlugin) Navigation() []pact.NavigationItem {
Permissions: []string{"acme.deferred.access"}}}
}
// AdminFS is the YAML tree under testdata/deferred.
func (dfPlugin) AdminFS() fs.FS { return os.DirFS(filepath.Join("testdata", "deferred")) }
// AdminFS is the YAML tree under testdata/deferred, or the test's overlay.
func (p dfPlugin) AdminFS() fs.FS {
if p.fsys != nil {
return p.fsys
}
return os.DirFS(filepath.Join("testdata", "deferred"))
}
// dfOverlay is the testdata tree with files replaced; an empty value
// removes the file.
func dfOverlay(t *testing.T, files map[string]string) fs.FS {
t.Helper()
base := os.DirFS(filepath.Join("testdata", "deferred"))
out := fstest.MapFS{}
err := fs.WalkDir(base, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := fs.ReadFile(base, path)
if err != nil {
return err
}
out[path] = &fstest.MapFile{Data: data}
return nil
})
if err != nil {
t.Fatal(err)
}
for name, data := range files {
if data == "" {
delete(out, name)
continue
}
out[name] = &fstest.MapFile{Data: []byte(data)}
}
return out
}
// dfFile reads one testdata file, for a test that edits it.
func dfFile(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", "deferred", filepath.FromSlash(name)))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// activateDeferred boots cabana with the fixture plugin p (no database)
// and returns the boot error.
func activateDeferred(t *testing.T, p dfPlugin, config map[string]any) error {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-deferred-boot\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
for k, v := range config {
if err := cfg.Set(k, v); err != nil {
t.Fatal(err)
}
}
if p.rec == nil {
p.rec = &dfRecorder{fail: map[string]bool{}}
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p})
return err
}
type dfController struct {
rec *dfRecorder
id string
dir string
// contracts, when set, replaces the relation contracts.
contracts func() []cabana.RelationContract
}
func (c dfController) ID() string { return c.id }
@@ -221,7 +311,16 @@ func (dfController) ModelName() string { return "Gadget" }
func (c dfController) ConfigDir() string { return c.dir }
func (dfController) RequiredPermissions() []string { return []string{"acme.deferred.access"} }
func (dfController) NewRecord() any { return &dfGadget{} }
func (dfController) AdminRelationContracts() []cabana.RelationContract {
func (c dfController) AdminRelationContracts() []cabana.RelationContract {
if c.contracts != nil {
return c.contracts()
}
return dfContracts()
}
// dfContracts are the fixture's relation contracts: members (belongsToMany
// by the empty Kind) and parts (hasMany on the nullable gadget_id).
func dfContracts() []cabana.RelationContract {
return []cabana.RelationContract{{
Name: "members", NewRelated: func() any { return &dfMember{} }, NewPivot: func() any { return &dfGadgetMember{} },
ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"},
@@ -232,6 +331,15 @@ func (dfController) AdminRelationContracts() []cabana.RelationContract {
}}
}
// AdminActions registers one widget action, so a relation form's widget
// field reaches the D-23 refusal instead of failing on its action.
func (dfController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{{Name: "lookup", Label: "Look up", Permissions: []string{"acme.deferred.access"},
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
return pact.AdminActionResult{}, nil
}}}
}
// FormExtendQuery hides gadgets marked hidden.
func (dfController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("hidden = ?", false)
@@ -298,6 +406,13 @@ type dfClient struct {
var dfModels = []any{&dfGadget{}, &dfPart{}, &dfMember{}, &dfGadgetMember{}}
func newDeferredEnv(t *testing.T) *dfEnv {
t.Helper()
return newDeferredEnvWith(t, dfPlugin{})
}
// newDeferredEnvWith assembles the router over the plugin p (its recorder
// is replaced by the env's).
func newDeferredEnvWith(t *testing.T, p dfPlugin) *dfEnv {
t.Helper()
gdb := adminGorm(t)
if err := gdb.Migrator().DropTable(dfModels...); err != nil {
@@ -339,7 +454,8 @@ func newDeferredEnv(t *testing.T) *dfEnv {
t.Fatal(err)
}
rec := &dfRecorder{fail: map[string]bool{}}
plugins := []party.Plugin{dfPlugin{rec: rec}}
p.rec = rec
plugins := []party.Plugin{p}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
@@ -572,11 +688,19 @@ func (e *dfEnv) state(t *testing.T) dfState {
for _, q := range []struct {
dest any
order string
}{{&s.Gadgets, "id"}, {&s.Members, "id"}, {&s.Pivots, "id"}, {&s.Files, "id"}, {&s.Bindings, "id"}} {
}{{&s.Gadgets, "id"}, {&s.Members, "id"}, {&s.Pivots, "id"}} {
if err := e.db.Order(q.order).Find(q.dest).Error; err != nil {
t.Fatal(err)
}
}
// Other tests share the database: only the fixture's files (and the
// unattached ones, cleared when the env starts) and bindings count.
if err := e.db.Where("attachment_type IN ? OR attachment_id IS NULL OR attachment_id = ''", []string{dfGadgetMorph, dfPartMorph}).Order("id").Find(&s.Files).Error; err != nil {
t.Fatal(err)
}
if err := e.db.Where("master_type IN ?", []string{dfGadgetMorph, dfPartMorph}).Order("id").Find(&s.Bindings).Error; err != nil {
t.Fatal(err)
}
if err := e.db.Unscoped().Order("id").Find(&s.Parts).Error; err != nil {
t.Fatal(err)
}