- 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
748 lines
24 KiB
Go
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
|
|
}
|