Files
base/apis/collection_import_test.go
zeekayandHanzo Dev d8cfc2b97b tests: stop asserting numbers and strings that were never ours to pin
Every failure in this repo came from an expectation that was correct when
written and had no way to stay correct.

Collection counts (apis x6, core x3). `27` was hand-derived in a comment —
"17 framework/system + demo collections plus the 10 CRM ... collections" — and
the delete-event counts were a second hand-derivation, `9 + 10 = 19`. The
calendar-booking migration made them 31 and 23 and both files had been failing
since. Now read from the app: the total from CollectionQuery, and the deletes as
"all non-system, less the one the payload updates". A count of EVERYTHING is
invalidated by every migration that adds anything, so it cannot be a literal.

`SQL logic error` (apis x2). SQLite now says `near "invalid": syntax error`.
That assertion pinned a THIRD-PARTY string; the tests around it already check
status 400, `"data":{}`, our own `Raw error:` prefix, and — for the rollback
case — that the table was not created. Those are ours and they are the point.

IAM mock paths (iam x12). The mock still served `/api/get-users` and
`/api/add-user` after the client moved to `/v1/iam/*`. The client had a stale
doc comment saying `/api/add-user` too. Both fixed; the four tests were failing
with a bare `404 page not found`, which names nothing.

And one that was NOT a stale test. tools/search generates SQL calling acos, cos,
sin, radians and sqrt. The pure-Go backend (CGO_ENABLED=0) has them and that is
what the Dockerfile ships, so production is fine — but the cgo backend only gets
them behind csqlite `sqlite_math_functions`, so a cgo build silently has a
SMALLER SQL surface than the code written against it, and nothing says so until
a geoDistance filter returns "no such function: acos" from an endpoint that
works in prod. core/sqlite_math_required.go makes that combination a build
error naming the fix. Two build modes that answer differently is the bug.

Verified: full suite green as shipped (CGO_ENABLED=0), core twice.
network/ does not import core, so the cgo CI attack suite is unaffected.
TestNotifyWatcher_CollectionsUpdate is a pre-existing timing flake — passed 3/3
alone and on both full re-runs.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 07:34:22 -07:00

377 lines
12 KiB
Go

package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
)
func TestCollectionsImport(t *testing.T) {
t.Parallel()
// Derived, not counted by hand: every migration that adds a collection
// changes both of these, and the literals they replace (27 and 19) had gone
// stale behind the calendar-booking collections.
totalCollections, nonSystemCollections := collectionCounts(t)
// The create/update/delete payload updates ONE existing collection and
// creates one that did not exist. System collections are never deleted, and
// every other non-system collection is absent from the payload, so it goes.
// Hence: all non-system, less the single one the payload updates.
deleted := nonSystemCollections - 1
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPut,
URL: "/v1/collections/import",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodPut,
URL: "/v1/collections/import",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser + empty collections",
Method: http.MethodPut,
URL: "/v1/collections/import",
Body: strings.NewReader(`{"collections":[]}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"validation_required"`,
},
ExpectedEvents: map[string]int{"*": 0},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
collections := []*core.Collection{}
if err := app.CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
{
Name: "authorized as superuser + collections validator failure",
Method: http.MethodPut,
URL: "/v1/collections/import",
Body: strings.NewReader(`{
"collections":[
{"name": "import1"},
{
"name": "import2",
"fields": [
{
"id": "koih1lqx",
"name": "expand",
"type": "text"
}
]
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"validation_collections_import_failure"`,
`import2`,
`fields`,
},
NotExpectedContent: []string{"Raw error:"},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionsImportRequest": 1,
"OnCollectionCreate": 2,
"OnCollectionCreateExecute": 2,
"OnCollectionAfterCreateError": 2,
"OnModelCreate": 2,
"OnModelCreateExecute": 2,
"OnModelAfterCreateError": 2,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
collections := []*core.Collection{}
if err := app.CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
{
Name: "authorized as superuser + non-validator failure",
Method: http.MethodPut,
URL: "/v1/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"fields": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"name": "import2",
"fields": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"validation_collections_import_failure"`,
`Raw error:`,
`custom_error`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionsImportRequest": 1,
"OnCollectionCreate": 1,
"OnCollectionAfterCreateError": 1,
"OnModelCreate": 1,
"OnModelAfterCreateError": 1,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionCreate().BindFunc(func(e *core.CollectionEvent) error {
return errors.New("custom_error")
})
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
collections := []*core.Collection{}
if err := app.CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
{
Name: "authorized as superuser + successful collections create",
Method: http.MethodPut,
URL: "/v1/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"fields": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"name": "import2",
"fields": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
},
{
"name": "auth_without_fields",
"type": "auth"
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionsImportRequest": 1,
"OnCollectionCreate": 3,
"OnCollectionCreateExecute": 3,
"OnCollectionAfterCreateSuccess": 3,
"OnModelCreate": 3,
"OnModelCreateExecute": 3,
"OnModelAfterCreateSuccess": 3,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
collections := []*core.Collection{}
if err := app.CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := totalCollections + 3
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
indexes, err := app.TableIndexes("import2")
if err != nil || indexes["idx_test"] == "" {
t.Fatalf("Missing index %s (%v)", "idx_test", err)
}
},
},
{
Name: "authorized as superuser + create/update/delete",
Method: http.MethodPut,
URL: "/v1/collections/import",
Body: strings.NewReader(`{
"deleteMissing": true,
"collections":[
{"name": "test123"},
{
"id":"wsmn24bux7wo113",
"name":"demo1",
"fields":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"required":true
}
],
"indexes": []
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionsImportRequest": 1,
// ---
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnCollectionCreate": 1,
"OnCollectionCreateExecute": 1,
"OnCollectionAfterCreateSuccess": 1,
// ---
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnCollectionUpdate": 1,
"OnCollectionUpdateExecute": 1,
"OnCollectionAfterUpdateSuccess": 1,
// ---
// Every non-system collection absent from the payload is deleted.
"OnModelDelete": deleted,
"OnModelAfterDeleteSuccess": deleted,
"OnModelDeleteExecute": deleted,
"OnCollectionDelete": deleted,
"OnCollectionDeleteExecute": deleted,
"OnCollectionAfterDeleteSuccess": deleted,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
collections := []*core.Collection{}
if err := app.CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
systemCollections := 0
for _, c := range collections {
if c.System {
systemCollections++
}
}
expected := systemCollections + 2
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
{
Name: "OnCollectionsImportRequest tx body write check",
Method: http.MethodPut,
URL: "/v1/collections/import",
Body: strings.NewReader(`{
"deleteMissing": true,
"collections":[
{"name": "test123"},
{
"id":"wsmn24bux7wo113",
"name":"demo1",
"fields":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"required":true
}
],
"indexes": []
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionsImportRequest().BindFunc(func(e *core.CollectionsImportRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnCollectionsImportRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}