Files
summercms/modules/cabana/phase122_fixture_test.go
Jakub Zych c8d41a68c6 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
2026-10-02 20:34:23 +02:00

748 lines
24 KiB
Go

package cabana_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// The acme.deferred fixture plugin backs the Phase 12.2 unit, integration
// and security tests. It is defined only in this test file, with its YAML
// tree under testdata/deferred, and is never registered by an application.
//
// Controllers:
// - acme.deferred.gadgets: datepicker fields (date, datetime, time), a
// public attachMany `photos` (image, maxFiles 3) and a protected
// attachOne `manual` (file mode), a belongsToMany `members` relation
// with manage and pivot forms (RelationBeforeLink stamps `role`), and a
// deferrable hasMany `parts` relation whose manage form has a fileupload
// and a datepicker field. Every toolbar button is declared.
// - acme.deferred.locked: the same model and relations with a reduced
// toolbar (parts: link; members: unlink) and a required fileupload.
//
// FormExtendQuery hides gadgets whose `hidden` column is true. Every Form
// and Relation hook records its calls in the env's recorder, which can also
// make a named hook fail.
const (
dfGadgetMorph = "acme.deferred.gadget"
dfPartMorph = "acme.deferred.part"
)
type dfGadget struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
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 without time zone"`
Members []dfMember `gorm:"-"`
Parts []dfPart `gorm:"-"`
// 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" }
func (dfGadget) MorphName() string { return dfGadgetMorph }
func (dfGadget) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}}
}
func (dfGadget) Fillable() []string {
return []string{"name", "released_on", "starts_at", "opens_at"}
}
func (dfGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
// dfPart is the hasMany child; gadget_id is nullable so the relation is
// deferrable, and DeletedAt makes a relation delete a soft delete.
type dfPart struct {
ID uint `gorm:"column:id;primaryKey"`
GadgetID *uint `gorm:"column:gadget_id"`
Label string `gorm:"column:label"`
DueOn *lagoon.Date `gorm:"column:due_on;type:date"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"`
}
func (dfPart) TableName() string { return "cabana_deferred_parts" }
func (dfPart) MorphName() string { return dfPartMorph }
func (dfPart) Fillable() []string { return []string{"label", "due_on"} }
func (dfPart) Rules() map[string]string { return map[string]string{"label": "required"} }
func (dfPart) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: "images", Many: true}, {Name: "sheet"}}
}
// dfPartHooks records the part model's delete hook calls; a relation
// delete must go through the model so the hook runs.
var dfPartHooks dfRecorder
// BeforeDelete is the part's model hook.
func (p *dfPart) BeforeDelete(*gorm.DB) error {
dfPartHooks.add(fmt.Sprintf("BeforeDelete:%d", p.ID))
return nil
}
type dfMember struct {
ID uint `gorm:"column:id;primaryKey"`
Email string `gorm:"column:email"`
}
func (dfMember) TableName() string { return "cabana_deferred_members" }
func (dfMember) Fillable() []string { return []string{"email"} }
func (dfMember) Rules() map[string]string { return map[string]string{"email": "required"} }
// dfGadgetMember is the members pivot: note comes from the pivot form, role
// is the hook column RelationBeforeLink stamps.
type dfGadgetMember struct {
ID uint `gorm:"column:id;primaryKey"`
GadgetID uint `gorm:"column:gadget_id"`
MemberID uint `gorm:"column:member_id"`
Note string `gorm:"column:note"`
Role string `gorm:"column:role"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (dfGadgetMember) TableName() string { return "cabana_deferred_gadget_members" }
// dfRecorder records hook calls and can make a named hook fail.
type dfRecorder struct {
mu sync.Mutex
calls []string
fail map[string]bool
// probe, when set, runs inside the Form hooks with the write's
// transaction and its result is recorded after the hook name.
probe func(tx *gorm.DB) string
}
func (r *dfRecorder) add(call string) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, call)
}
func (r *dfRecorder) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
r.fail = map[string]bool{}
r.probe = nil
}
func (r *dfRecorder) failOn(name string) {
r.mu.Lock()
defer r.mu.Unlock()
r.fail[name] = true
}
func (r *dfRecorder) snapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.calls...)
}
// hook records name (with the probe's result) and fails when asked to.
func (r *dfRecorder) hook(ctx context.Context, name string) error {
r.mu.Lock()
probe := r.probe
fail := r.fail[name]
r.mu.Unlock()
call := name
if probe != nil {
if tx, ok := cabana.TxFromContext(ctx); ok {
call += ":" + probe(tx)
}
}
r.add(call)
if fail {
return errors.New("fixture hook " + name + " failed")
}
return nil
}
// 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"},
}
}
// Models lists every fixture model deferred:purge may delete.
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"}}}
}
func (dfPlugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{Code: "deferred", Label: "Deferred", Icon: "box", Order: 10, Controller: "acme.deferred.gadgets",
Permissions: []string{"acme.deferred.access"}}}
}
// 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 }
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 (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"},
HookPivotColumns: []string{"role"},
}, {
Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &dfPart{} },
ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"},
}}
}
// 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)
}
// RelationBeforeLink stamps the server-owned role of a members link.
func (c dfController) RelationBeforeLink(ctx context.Context, relation string, _, _ any, pivot map[string]any) error {
if relation == "members" {
pivot["role"] = "linked"
}
return c.rec.hook(ctx, "RelationBeforeLink:"+relation)
}
func (c dfController) FormBeforeCreate(ctx context.Context, _ any) error {
return c.rec.hook(ctx, "FormBeforeCreate")
}
func (c dfController) FormAfterCreate(ctx context.Context, _ any) error {
return c.rec.hook(ctx, "FormAfterCreate")
}
func (c dfController) FormBeforeUpdate(ctx context.Context, _ any) error {
return c.rec.hook(ctx, "FormBeforeUpdate")
}
func (c dfController) FormAfterUpdate(ctx context.Context, _ any) error {
return c.rec.hook(ctx, "FormAfterUpdate")
}
func (c dfController) RelationBeforeCreate(ctx context.Context, relation string, _, _ any) error {
return c.rec.hook(ctx, "RelationBeforeCreate:"+relation)
}
func (c dfController) RelationAfterCreate(ctx context.Context, relation string, _, _ any) error {
return c.rec.hook(ctx, "RelationAfterCreate:"+relation)
}
func (c dfController) RelationBeforeUpdate(ctx context.Context, relation string, _, _ any) error {
return c.rec.hook(ctx, "RelationBeforeUpdate:"+relation)
}
func (c dfController) RelationAfterUpdate(ctx context.Context, relation string, _, _ any) error {
return c.rec.hook(ctx, "RelationAfterUpdate:"+relation)
}
func (c dfController) RelationBeforeDelete(ctx context.Context, relation string, _, _ any) error {
return c.rec.hook(ctx, "RelationBeforeDelete:"+relation)
}
func (c dfController) RelationAfterDelete(ctx context.Context, relation string, _, _ any) error {
return c.rec.hook(ctx, "RelationAfterDelete:"+relation)
}
// dfEnv is the assembled router over the acme.deferred fixture on the
// cabana Postgres harness, with two admins (A and B) holding the
// controller permission.
type dfEnv struct {
h http.Handler
db *gorm.DB
bucket *blob.Bucket
rec *dfRecorder
stamp string
a, b dfClient
}
// dfClient sends requests as one admin.
type dfClient struct {
env *dfEnv
id uint
token string
}
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 {
t.Fatal(err)
}
if err := gdb.AutoMigrate(dfModels...); err != nil {
t.Fatal(err)
}
// Ids restart with the recreated tables: drop the files and bindings an
// earlier run left for them.
if err := gdb.Exec(`DELETE FROM system_files WHERE attachment_type IN (?, ?) OR attachment_id IS NULL OR attachment_id = ''`, dfGadgetMorph, dfPartMorph).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`DELETE FROM deferred_bindings WHERE master_type IN (?, ?)`, dfGadgetMorph, dfPartMorph).Error; err != nil {
t.Fatal(err)
}
stamp := fmt.Sprintf("d%d", time.Now().UnixNano())
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-deferred\nlocale: en\nfallback_locale: en\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 key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
rec := &dfRecorder{fail: map[string]bool{}}
p.rec = rec
plugins := []party.Plugin{p}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
env := &dfEnv{h: h, db: gdb, bucket: bucket, rec: rec, stamp: stamp}
env.a = env.login(t, "dfa-"+stamp)
env.b = env.login(t, "dfb-"+stamp)
return env
}
// login inserts an admin and logs it in.
func (e *dfEnv) login(t *testing.T, login string) dfClient {
t.Helper()
user := insertAdmin(t, e.db, login, login+"@example.test", adminTestPassword, true, false)
raw, _ := json.Marshal(map[string]string{"login": login, "password": adminTestPassword})
req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/login"), bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String())
}
return dfClient{env: e, id: user.ID, token: accessToken(t, rec.Body.Bytes())}
}
// do sends a request as the client. body is nil, raw []byte (with
// contentType from headers["Content-Type"]) or a value sent as JSON.
func (c dfClient) do(t *testing.T, method, rel string, body any, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
var payload []byte
contentType := ""
switch b := body.(type) {
case nil:
case []byte:
payload = b
default:
raw, err := json.Marshal(b)
if err != nil {
t.Fatal(err)
}
payload = raw
contentType = "application/json"
}
req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(payload))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Accept-Language", "en")
req.Header.Set("Authorization", "Bearer "+c.token)
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
c.env.h.ServeHTTP(rec, req)
return rec
}
// upload posts one multipart file_data part to rel.
func (c dfClient) upload(t *testing.T, rel, name string, data []byte, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file_data", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
h := map[string]string{"Content-Type": mw.FormDataContentType()}
for k, v := range headers {
h[k] = v
}
return c.do(t, http.MethodPost, rel, buf.Bytes(), h)
}
// dfPath is a gadgets controller path: gadget id and the rest.
func dfPath(gadget uint, rest string) string {
return fmt.Sprintf("/acme/deferred/gadgets/%d%s", gadget, rest)
}
// dfLockedPath is a locked controller path.
func dfLockedPath(gadget uint, rest string) string {
return fmt.Sprintf("/acme/deferred/locked/%d%s", gadget, rest)
}
// sk is the X-Session-Key header map; ck adds X-Child-Session-Key.
func sk(key string) map[string]string { return map[string]string{cabana.SessionKeyHeader: key} }
func ck(key string) map[string]string { return map[string]string{cabana.ChildSessionKeyHeader: key} }
// want fails the test unless rec has the status.
func want(t *testing.T, what string, rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status {
t.Fatalf("%s: status=%d want %d body=%s", what, rec.Code, status, rec.Body.String())
}
}
// errorCode is the error envelope's code.
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("error envelope: %v\n%s", err, rec.Body.String())
}
return body.Error.Code
}
// errorDetails is the error envelope's details.
func errorDetails(t *testing.T, rec *httptest.ResponseRecorder) map[string][]string {
t.Helper()
var body struct {
Error struct {
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("error envelope: %v\n%s", err, rec.Body.String())
}
return body.Error.Details
}
// gadget inserts a gadget straight into the table.
func (e *dfEnv) gadget(t *testing.T, name string, hidden bool) uint {
t.Helper()
g := dfGadget{Name: name, Hidden: hidden}
if err := e.db.Create(&g).Error; err != nil {
t.Fatal(err)
}
if hidden {
if err := e.db.Model(&g).Update("hidden", true).Error; err != nil {
t.Fatal(err)
}
}
return g.ID
}
// member inserts a member straight into the table.
func (e *dfEnv) member(t *testing.T, email string) uint {
t.Helper()
m := dfMember{Email: email}
if err := e.db.Create(&m).Error; err != nil {
t.Fatal(err)
}
return m.ID
}
// part inserts a part owned by gadget (0 for none) straight into the table.
func (e *dfEnv) part(t *testing.T, gadget uint, label string) uint {
t.Helper()
p := dfPart{Label: label}
if gadget > 0 {
p.GadgetID = &gadget
}
if err := e.db.Create(&p).Error; err != nil {
t.Fatal(err)
}
return p.ID
}
// pivot links a member to a gadget straight in the pivot table.
func (e *dfEnv) pivot(t *testing.T, gadget, member uint, note string) {
t.Helper()
if err := e.db.Create(&dfGadgetMember{GadgetID: gadget, MemberID: member, Note: note, Role: "seed"}).Error; err != nil {
t.Fatal(err)
}
}
// partRow reads a part, soft-deleted rows included; ok is false when the
// row is gone.
func (e *dfEnv) partRow(t *testing.T, id uint) (dfPart, bool) {
t.Helper()
var p dfPart
err := e.db.Unscoped().Where("id = ?", id).Take(&p).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return dfPart{}, false
}
if err != nil {
t.Fatal(err)
}
return p, true
}
// storeFile stores data as a file of the owner (attached when ownerID is
// set) through attach.Store, bypassing the routes.
func (e *dfEnv) storeFile(t *testing.T, morph string, ownerID uint, field, name string, data []byte, public bool) attach.File {
t.Helper()
f, err := attach.Store(context.Background(), e.db, e.bucket, attach.Upload{FileName: name, Body: bytes.NewReader(data), Public: public}, attach.Limits{Extensions: []string{"png", "gif", "jpg", "webp", "svg", "html", "txt", "pdf"}})
if err != nil {
t.Fatalf("store %s: %v", name, err)
}
if ownerID > 0 {
if err := e.db.Model(&attach.File{}).Where("id = ?", f.ID).
Updates(map[string]any{"attachment_type": morph, "attachment_id": fmt.Sprint(ownerID), "field": field}).Error; err != nil {
t.Fatal(err)
}
}
var out attach.File
if err := e.db.Where("id = ?", f.ID).Take(&out).Error; err != nil {
t.Fatal(err)
}
return out
}
// dfState is a snapshot of every fixture table, the files and the
// bindings, compared before and after a refused request.
type dfState struct {
Gadgets []dfGadget
Parts []dfPart
Members []dfMember
Pivots []dfGadgetMember
Files []attach.File
Bindings []lagoon.DeferredBinding
Blobs int
}
func (e *dfEnv) state(t *testing.T) dfState {
t.Helper()
var s dfState
for _, q := range []struct {
dest any
order string
}{{&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)
}
iter := e.bucket.List(nil)
for {
if _, err := iter.Next(context.Background()); err != nil {
break
}
s.Blobs++
}
return s
}
// unchanged fails unless the state equals before.
func (e *dfEnv) unchanged(t *testing.T, what string, before dfState) {
t.Helper()
after := e.state(t)
a, _ := json.Marshal(before)
b, _ := json.Marshal(after)
if !bytes.Equal(a, b) {
t.Fatalf("%s changed the database:\nbefore %s\nafter %s", what, a, b)
}
}
// dfIDs lists the data[].id values of a list response.
func dfIDs(t *testing.T, rec *httptest.ResponseRecorder) []uint {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
out := make([]uint, 0, len(body.Data))
for _, row := range body.Data {
out = append(out, row.ID)
}
return out
}