Files
base/core/collection_model_test.go
zeekay 23cf129160 Field types for people and money, and the caller's org authority in rules
Seven field types a CRM record needs, each one JSON column whose parts a
filter, a sort or an aggregate reaches by name: currency (amountMicros,
currencyCode), emails, phones, links, address, fullName and actor, plus
rating. Empty is NULL. Emails, phones and links normalize what is typed and
can keep their primary part unique. An actor is stamped by the server from
the writer the save's context states; nothing a request sends sets it.

Every field carries a label, description, icon and position, a select's
values carry a label, colour and position, and a base collection carries a
label, plural, icon, description and an opt-in history flag.

Editor HTML is sanitized before it is written.

A rule reads the caller's authority in the org it acts in:
@request.auth.admin and @request.auth.role, off the token. An IAM-mirrored
auth record is never saved, so its fields are now read off the record
rather than joined against a row that is not there, which also makes
@request.auth.org_id match for the first time. A host that opens one Base
per org names it, and a token acting in another org is refused there.
2026-10-18 20:41:15 -07:00

1631 lines
41 KiB
Go

package core_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"testing"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tests"
"github.com/hanzoai/base/tools/dbutils"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/base/tools/types"
"github.com/hanzoai/dbx"
)
func TestNewCollection(t *testing.T) {
t.Parallel()
scenarios := []struct {
typ string
name string
expected []string
}{
{
"",
"",
[]string{
`"id":"hbc_`,
`"name":""`,
`"type":"base"`,
`"system":false`,
`"indexes":[]`,
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
{
"unknown",
"test",
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"base"`,
`"system":false`,
`"indexes":[]`,
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
{
"base",
"test",
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"base"`,
`"system":false`,
`"indexes":[]`,
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
{
"view",
"test",
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"view"`,
`"indexes":[]`,
`"fields":[]`,
`"system":false`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
{
"auth",
"test",
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"auth"`,
`"fields":[{`,
`"system":false`,
`"type":"text"`,
`"type":"email"`,
`"name":"id"`,
`"name":"email"`,
`"name":"tokenKey"`,
`"name":"emailVisibility"`,
`"name":"verified"`,
`idx_email`,
`idx_tokenKey`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s_%s", i, s.typ, s.name), func(t *testing.T) {
result := core.NewCollection(s.typ, s.name).String()
for _, part := range s.expected {
if !strings.Contains(result, part) {
t.Fatalf("Missing part %q in\n%v", part, result)
}
}
})
}
}
func TestNewBaseCollection(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
expected []string
}{
{
"",
[]string{
`"id":"hbc_`,
`"name":""`,
`"type":"base"`,
`"system":false`,
`"indexes":[]`,
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
{
"test",
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"base"`,
`"system":false`,
`"indexes":[]`,
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
result := core.NewBaseCollection(s.name).String()
for _, part := range s.expected {
if !strings.Contains(result, part) {
t.Fatalf("Missing part %q in\n%v", part, result)
}
}
})
}
}
func TestNewViewCollection(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
expected []string
}{
{
"",
[]string{
`"id":"hbc_`,
`"name":""`,
`"type":"view"`,
`"indexes":[]`,
`"fields":[]`,
`"system":false`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
{
"test",
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"view"`,
`"indexes":[]`,
`"fields":[]`,
`"system":false`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
result := core.NewViewCollection(s.name).String()
for _, part := range s.expected {
if !strings.Contains(result, part) {
t.Fatalf("Missing part %q in\n%v", part, result)
}
}
})
}
}
func TestNewAuthCollection(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
expected []string
}{
{
"",
[]string{
`"id":""`,
`"name":""`,
`"type":"auth"`,
`"fields":[{`,
`"system":false`,
`"type":"text"`,
`"type":"email"`,
`"name":"id"`,
`"name":"email"`,
`"name":"tokenKey"`,
`"name":"emailVisibility"`,
`"name":"verified"`,
`idx_email`,
`idx_tokenKey`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
{
"test",
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"auth"`,
`"fields":[{`,
`"system":false`,
`"type":"text"`,
`"type":"email"`,
`"name":"id"`,
`"name":"email"`,
`"name":"tokenKey"`,
`"name":"emailVisibility"`,
`"name":"verified"`,
`idx_email`,
`idx_tokenKey`,
`"listRule":null`,
`"viewRule":null`,
`"createRule":null`,
`"updateRule":null`,
`"deleteRule":null`,
},
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
result := core.NewAuthCollection(s.name).String()
for _, part := range s.expected {
if !strings.Contains(result, part) {
t.Fatalf("Missing part %q in\n%v", part, result)
}
}
})
}
}
func TestCollectionTableName(t *testing.T) {
t.Parallel()
c := core.NewBaseCollection("test")
if c.TableName() != "_collections" {
t.Fatalf("Expected tableName %q, got %q", "_collections", c.TableName())
}
}
func TestCollectionBaseFilesPath(t *testing.T) {
t.Parallel()
c := core.Collection{}
if c.BaseFilesPath() != "" {
t.Fatalf("Expected empty string, got %q", c.BaseFilesPath())
}
c.Id = "test"
if c.BaseFilesPath() != c.Id {
t.Fatalf("Expected %q, got %q", c.Id, c.BaseFilesPath())
}
}
func TestCollectionIsBase(t *testing.T) {
t.Parallel()
scenarios := []struct {
typ string
expected bool
}{
{"unknown", false},
{core.CollectionTypeBase, true},
{core.CollectionTypeView, false},
{core.CollectionTypeAuth, false},
}
for _, s := range scenarios {
t.Run(s.typ, func(t *testing.T) {
c := core.Collection{}
c.Type = s.typ
if v := c.IsBase(); v != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, v)
}
})
}
}
func TestCollectionIsView(t *testing.T) {
t.Parallel()
scenarios := []struct {
typ string
expected bool
}{
{"unknown", false},
{core.CollectionTypeBase, false},
{core.CollectionTypeView, true},
{core.CollectionTypeAuth, false},
}
for _, s := range scenarios {
t.Run(s.typ, func(t *testing.T) {
c := core.Collection{}
c.Type = s.typ
if v := c.IsView(); v != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, v)
}
})
}
}
func TestCollectionIsAuth(t *testing.T) {
t.Parallel()
scenarios := []struct {
typ string
expected bool
}{
{"unknown", false},
{core.CollectionTypeBase, false},
{core.CollectionTypeView, false},
{core.CollectionTypeAuth, true},
}
for _, s := range scenarios {
t.Run(s.typ, func(t *testing.T) {
c := core.Collection{}
c.Type = s.typ
if v := c.IsAuth(); v != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, v)
}
})
}
}
func TestCollectionPostScan(t *testing.T) {
t.Parallel()
rawOptions := types.JSONRaw(`{
"viewQuery":"select 1",
"authRule":"1=2"
}`)
scenarios := []struct {
typ string
rawOptions types.JSONRaw
expected []string
}{
{
core.CollectionTypeBase,
rawOptions,
[]string{
`lastSavedPK:"test"`,
`ViewQuery:""`,
`AuthRule:(*string)(nil)`,
},
},
{
core.CollectionTypeView,
rawOptions,
[]string{
`lastSavedPK:"test"`,
`ViewQuery:"select 1"`,
`AuthRule:(*string)(nil)`,
},
},
{
core.CollectionTypeAuth,
rawOptions,
[]string{
`lastSavedPK:"test"`,
`ViewQuery:""`,
`AuthRule:(*string)(0x`,
},
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.typ), func(t *testing.T) {
c := core.Collection{}
c.Id = "test"
c.Type = s.typ
c.RawOptions = s.rawOptions
err := c.PostScan()
if err != nil {
t.Fatal(err)
}
if c.IsNew() {
t.Fatal("Expected the collection to be marked as not new")
}
rawModel := fmt.Sprintf("%#v", c)
for _, part := range s.expected {
if !strings.Contains(rawModel, part) {
t.Fatalf("Missing part %q in\n%v", part, rawModel)
}
}
})
}
}
func TestCollectionUnmarshalJSON(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
raw string
collection func() *core.Collection
expected []string
notExpected []string
}{
{
"base new empty",
`{"type":"base","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
func() *core.Collection {
return &core.Collection{}
},
[]string{
`"type":"base"`,
`"id":"hbc_`,
`"name":"test"`,
`"listRule":"1=2"`,
`"fields":[`,
`"name":"id"`,
`"indexes":[]`,
},
[]string{
`"authRule":"1=3"`,
`"viewQuery":"abc"`,
},
},
{
"view new empty",
`{"type":"view","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
func() *core.Collection {
return &core.Collection{}
},
[]string{
`"type":"view"`,
`"id":"hbc_`,
`"name":"test"`,
`"listRule":"1=2"`,
`"fields":[]`,
`"viewQuery":"abc"`,
`"indexes":[]`,
},
[]string{
`"authRule":"1=3"`,
},
},
{
"auth new empty",
`{"type":"auth","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
func() *core.Collection {
return &core.Collection{}
},
[]string{
`"type":"auth"`,
`"id":"hbc_`,
`"name":"test"`,
`"listRule":"1=2"`,
`"authRule":"1=3"`,
`"fields":[`,
`"name":"id"`,
},
[]string{
`"indexes":[]`,
`"viewQuery":"abc"`,
},
},
{
"new but with set type (no default fields load)",
`{"type":"base","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
func() *core.Collection {
c := &core.Collection{}
c.Type = core.CollectionTypeBase
return c
},
[]string{
`"type":"base"`,
`"id":""`,
`"name":"test"`,
`"listRule":"1=2"`,
`"fields":[]`,
},
[]string{
`"authRule":"1=3"`,
`"viewQuery":"abc"`,
},
},
{
"existing (no default fields load)",
`{"type":"auth","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
func() *core.Collection {
c, _ := app.FindCollectionByNameOrId("demo1")
return c
},
[]string{
`"type":"auth"`,
`"name":"test"`,
`"listRule":"1=2"`,
`"authRule":"1=3"`,
`"fields":[`,
`"name":"id"`,
},
[]string{
`"name":"tokenKey"`,
`"viewQuery":"abc"`,
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
collection := s.collection()
err := json.Unmarshal([]byte(s.raw), collection)
if err != nil {
t.Fatal(err)
}
rawResult, err := json.Marshal(collection)
if err != nil {
t.Fatal(err)
}
rawResultStr := string(rawResult)
for _, part := range s.expected {
if !strings.Contains(rawResultStr, part) {
t.Fatalf("Missing expected %q in\n%v", part, rawResultStr)
}
}
for _, part := range s.notExpected {
if strings.Contains(rawResultStr, part) {
t.Fatalf("Didn't expected %q in\n%v", part, rawResultStr)
}
}
})
}
}
func TestCollectionSerialize(t *testing.T) {
scenarios := []struct {
name string
collection func() *core.Collection
expected []string
notExpected []string
}{
{
"base",
func() *core.Collection {
c := core.NewCollection(core.CollectionTypeBase, "test")
c.ViewQuery = "1=1"
c.OAuth2.Providers = []core.OAuth2ProviderConfig{
{Name: "test1", ClientId: "test_client_id1", ClientSecret: "test_client_secret1"},
{Name: "test2", ClientId: "test_client_id2", ClientSecret: "test_client_secret2"},
}
return c
},
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"base"`,
},
[]string{
"manageRule",
"authRule",
"secret",
"oauth2",
"clientId",
"clientSecret",
"viewQuery",
},
},
{
"view",
func() *core.Collection {
c := core.NewCollection(core.CollectionTypeView, "test")
c.ViewQuery = "1=1"
c.OAuth2.Providers = []core.OAuth2ProviderConfig{
{Name: "test1", ClientId: "test_client_id1", ClientSecret: "test_client_secret1"},
{Name: "test2", ClientId: "test_client_id2", ClientSecret: "test_client_secret2"},
}
return c
},
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"view"`,
`"viewQuery":"1=1"`,
},
[]string{
"manageRule",
"authRule",
"secret",
"oauth2",
"clientId",
"clientSecret",
},
},
{
"auth",
func() *core.Collection {
c := core.NewCollection(core.CollectionTypeAuth, "test")
c.ViewQuery = "1=1"
c.OAuth2.Providers = []core.OAuth2ProviderConfig{
{Name: "test1", ClientId: "test_client_id1", ClientSecret: "test_client_secret1"},
{Name: "test2", ClientId: "test_client_id2", ClientSecret: "test_client_secret2"},
}
return c
},
[]string{
`"id":"hbc_`,
`"name":"test"`,
`"type":"auth"`,
`"oauth2":{`,
`"providers":[{`,
`"clientId":"test_client_id1"`,
`"clientId":"test_client_id2"`,
},
[]string{
"viewQuery",
"secret",
"clientSecret",
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
collection := s.collection()
raw, err := collection.MarshalJSON()
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
if rawStr != collection.String() {
t.Fatalf("Expected the same serialization, got\n%v\nVS\n%v", collection.String(), rawStr)
}
for _, part := range s.expected {
if !strings.Contains(rawStr, part) {
t.Fatalf("Missing part %q in\n%v", part, rawStr)
}
}
for _, part := range s.notExpected {
if strings.Contains(rawStr, part) {
t.Fatalf("Didn't expect part %q in\n%v", part, rawStr)
}
}
})
}
}
func TestCollectionDBExport(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
date, err := types.ParseDateTime("2024-07-01 01:02:03.456Z")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
typ string
expected string
}{
{
"unknown",
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeBase,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{},"system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeView,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
{
core.CollectionTypeAuth,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"authToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.typ), func(t *testing.T) {
c := core.Collection{}
c.Type = s.typ
c.Id = "test_id"
c.Name = "test_name"
c.System = true
c.ListRule = types.Pointer("1=1")
c.ViewRule = types.Pointer("1=2")
c.CreateRule = types.Pointer("1=3")
c.UpdateRule = types.Pointer("1=4")
c.DeleteRule = types.Pointer("1=5")
c.ManageRule = types.Pointer("1=6")
c.ViewRule = types.Pointer("1=7")
c.Created = date
c.Updated = date
c.Indexes = types.JSONArray[string]{"CREATE INDEX idx1 on test_name(id)", "CREATE INDEX idx2 on test_name(id)"}
c.ViewQuery = "select 1"
c.Fields.Add(&core.BoolField{Id: "f1_id", Name: "f1", System: true})
c.Fields.Add(&core.BoolField{Id: "f2_id", Name: "f2", Required: true})
c.RawOptions = types.JSONRaw(`{"viewQuery": "select 2"}`) // should be ignored
result, err := c.DBExport(app)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if str := string(raw); str != s.expected {
t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str)
}
})
}
}
func TestCollectionIndexHelpers(t *testing.T) {
t.Parallel()
checkIndexes := func(t *testing.T, indexes, expectedIndexes []string) {
if len(indexes) != len(expectedIndexes) {
t.Fatalf("Expected %d indexes, got %d\n%v", len(expectedIndexes), len(indexes), indexes)
}
for _, idx := range expectedIndexes {
if !slices.Contains(indexes, idx) {
t.Fatalf("Missing index\n%v\nin\n%v", idx, indexes)
}
}
}
c := core.NewBaseCollection("test")
checkIndexes(t, c.Indexes, nil)
c.AddIndex("idx1", false, "colA,colB", "colA != 1")
c.AddIndex("idx2", true, "colA", "")
c.AddIndex("idx3", false, "colA", "")
c.AddIndex("idx3", false, "colB", "") // should overwrite the previous one
idx1 := "CREATE INDEX `idx1` ON `test` (colA,colB) WHERE colA != 1"
idx2 := "CREATE UNIQUE INDEX `idx2` ON `test` (colA)"
idx3 := "CREATE INDEX `idx3` ON `test` (colB)"
checkIndexes(t, c.Indexes, []string{idx1, idx2, idx3})
c.RemoveIndex("iDx2") // case-insensitive
c.RemoveIndex("missing") // noop
checkIndexes(t, c.Indexes, []string{idx1, idx3})
expectedIndexes := map[string]string{
"missing": "",
"idx1": idx1,
// the name is case insensitive
"iDX3": idx3,
}
for key, expectedIdx := range expectedIndexes {
idx := c.GetIndex(key)
if idx != expectedIdx {
t.Errorf("Expected index %q to be\n%v\ngot\n%v", key, expectedIdx, idx)
}
}
}
// -------------------------------------------------------------------
func TestCollectionDelete(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
collection string
disableIntegrityChecks bool
expectError bool
}{
{
name: "unsaved",
collection: "",
expectError: true,
},
{
name: "system",
collection: core.CollectionNameSuperusers,
expectError: true,
},
{
name: "base with references",
collection: "demo1",
expectError: true,
},
{
name: "base with references with disabled integrity checks",
collection: "demo1",
disableIntegrityChecks: true,
expectError: false,
},
{
name: "base without references",
collection: "demo1",
expectError: true,
},
{
name: "view with reference",
collection: "view1",
expectError: true,
},
{
name: "view with references with disabled integrity checks",
collection: "view1",
disableIntegrityChecks: true,
expectError: false,
},
{
name: "view without references",
collection: "view2",
disableIntegrityChecks: true,
expectError: false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
var col *core.Collection
if s.collection == "" {
col = core.NewBaseCollection("test")
} else {
var err error
col, err = app.FindCollectionByNameOrId(s.collection)
if err != nil {
t.Fatal(err)
}
}
if s.disableIntegrityChecks {
col.IntegrityChecks(!s.disableIntegrityChecks)
}
err := app.Delete(col)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
exists := app.HasTable(col.Name)
if !col.IsNew() && exists != hasErr {
t.Fatalf("Expected HasTable %v, got %v", hasErr, exists)
}
if !hasErr {
cache, _ := app.FindCachedCollectionByNameOrId(col.Id)
if cache != nil {
t.Fatal("Expected the collection to be removed from the cache.")
}
}
})
}
}
func TestCollectionModelEventSync(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
testCollections := make([]*core.Collection, 4)
for i := 0; i < 4; i++ {
testCollections[i] = core.NewBaseCollection("sync_test_" + strconv.Itoa(i))
if err := app.Save(testCollections[i]); err != nil {
t.Fatal(err)
}
}
createModelEvent := func() *core.ModelEvent {
event := new(core.ModelEvent)
event.App = app
event.Context = context.Background()
event.Type = "test_a"
event.Model = testCollections[0]
return event
}
createModelErrorEvent := func() *core.ModelErrorEvent {
event := new(core.ModelErrorEvent)
event.ModelEvent = *createModelEvent()
event.Error = errors.New("error_a")
return event
}
changeCollectionEventBefore := func(e *core.CollectionEvent) {
e.Type = "test_b"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 123)
e.Collection = testCollections[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testCollections[2]
}
changeCollectionEventAfter := func(e *core.CollectionEvent) {
e.Type = "test_d"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 789)
e.Collection = testCollections[3]
}
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Model.PK() != testCollections[0].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Model.PK(), 0)
}
}
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
if e.Type != "test_d" {
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
}
if v := e.Context.Value("test"); v != 789 {
t.Fatalf("Expected context value %v, got %v", 789, v)
}
if e.Model.PK() != testCollections[3].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[3].Id, e.Model.PK(), 3)
}
}
expectedBeforeCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
if e.Type != "test_a" {
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
}
if v := e.Context.Value("test"); v != nil {
t.Fatalf("Expected context value %v, got %v", nil, v)
}
if e.Collection.Id != testCollections[0].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Collection.Id, 0)
}
}
expectedAfterCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
if e.Type != "test_c" {
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
}
if v := e.Context.Value("test"); v != 456 {
t.Fatalf("Expected context value %v, got %v", 456, v)
}
if e.Collection.Id != testCollections[2].Id {
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[2].Id, e.Collection.Id, 2)
}
}
modelEventFinalizer := func(e *core.ModelEvent) error {
modelEventFinalizerChange(e)
return nil
}
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
modelEventFinalizerChange(&e.ModelEvent)
e.Error = errors.New("error_c")
return nil
}
modelEventHandler := &hook.Handler[*core.ModelEvent]{
Priority: -999,
Func: func(e *core.ModelEvent) error {
t.Run("before model", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, e)
})
_ = e.Next()
t.Run("after model", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, e)
})
return nil
},
}
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
Priority: -999,
Func: func(e *core.ModelErrorEvent) error {
t.Run("before model error", func(t *testing.T) {
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_a", v)
}
})
_ = e.Next()
t.Run("after model error", func(t *testing.T) {
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
if v := e.Error.Error(); v != "error_d" {
t.Fatalf("Expected error %q, got %q", "error_d", v)
}
})
return nil
},
}
recordEventHandler := &hook.Handler[*core.CollectionEvent]{
Priority: -999,
Func: func(e *core.CollectionEvent) error {
t.Run("before collection", func(t *testing.T) {
expectedBeforeCollectionEventHandlerChecks(t, e)
})
changeCollectionEventBefore(e)
_ = e.Next()
t.Run("after collection", func(t *testing.T) {
expectedAfterCollectionEventHandlerChecks(t, e)
})
changeCollectionEventAfter(e)
return nil
},
}
collectionErrorEventHandler := &hook.Handler[*core.CollectionErrorEvent]{
Priority: -999,
Func: func(e *core.CollectionErrorEvent) error {
t.Run("before collection error", func(t *testing.T) {
expectedBeforeCollectionEventHandlerChecks(t, &e.CollectionEvent)
if v := e.Error.Error(); v != "error_a" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeCollectionEventBefore(&e.CollectionEvent)
e.Error = errors.New("error_b")
_ = e.Next()
t.Run("after collection error", func(t *testing.T) {
expectedAfterCollectionEventHandlerChecks(t, &e.CollectionEvent)
if v := e.Error.Error(); v != "error_c" {
t.Fatalf("Expected error %q, got %q", "error_c", v)
}
})
changeCollectionEventAfter(&e.CollectionEvent)
e.Error = errors.New("error_d")
return nil
},
}
// OnModelValidate
app.OnCollectionValidate().Bind(recordEventHandler)
app.OnModelValidate().Bind(modelEventHandler)
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreate
app.OnCollectionCreate().Bind(recordEventHandler)
app.OnModelCreate().Bind(modelEventHandler)
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelCreateExecute
app.OnCollectionCreateExecute().Bind(recordEventHandler)
app.OnModelCreateExecute().Bind(modelEventHandler)
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateSuccess
app.OnCollectionAfterCreateSuccess().Bind(recordEventHandler)
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterCreateError
app.OnCollectionAfterCreateError().Bind(collectionErrorEventHandler)
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelUpdate
app.OnCollectionUpdate().Bind(recordEventHandler)
app.OnModelUpdate().Bind(modelEventHandler)
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelUpdateExecute
app.OnCollectionUpdateExecute().Bind(recordEventHandler)
app.OnModelUpdateExecute().Bind(modelEventHandler)
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateSuccess
app.OnCollectionAfterUpdateSuccess().Bind(recordEventHandler)
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterUpdateError
app.OnCollectionAfterUpdateError().Bind(collectionErrorEventHandler)
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
// OnModelDelete
app.OnCollectionDelete().Bind(recordEventHandler)
app.OnModelDelete().Bind(modelEventHandler)
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelDeleteExecute
app.OnCollectionDeleteExecute().Bind(recordEventHandler)
app.OnModelDeleteExecute().Bind(modelEventHandler)
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteSuccess
app.OnCollectionAfterDeleteSuccess().Bind(recordEventHandler)
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
// OnModelAfterDeleteError
app.OnCollectionAfterDeleteError().Bind(collectionErrorEventHandler)
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
}
func TestCollectionSaveModel(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
collection func(app core.App) (*core.Collection, error)
expectError bool
expectColumns []string
}{
// trigger validators
{
name: "create - trigger validators",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewBaseCollection("!invalid")
c.Fields.Add(&core.TextField{Name: "example"})
c.AddIndex("test_save_idx", false, "example", "")
return c, nil
},
expectError: true,
},
{
name: "update - trigger validators",
collection: func(app core.App) (*core.Collection, error) {
c, _ := app.FindCollectionByNameOrId("demo5")
c.Name = "demo1"
c.Fields.Add(&core.TextField{Name: "example"})
c.Fields.RemoveByName("file")
c.AddIndex("test_save_idx", false, "example", "")
return c, nil
},
expectError: true,
},
// create
{
name: "create base collection",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewBaseCollection("new")
c.Type = "" // should be auto set to "base"
c.Fields.RemoveByName("id") // ensure that the default fields will be loaded
c.Fields.Add(&core.TextField{Name: "example"})
c.AddIndex("test_save_idx", false, "example", "")
return c, nil
},
expectError: false,
expectColumns: []string{
"id", "example",
},
},
{
name: "create auth collection",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new")
c.Fields.RemoveByName("id") // ensure that the default fields will be loaded
c.Fields.RemoveByName("email") // ensure that the default fields will be loaded
c.Fields.Add(&core.TextField{Name: "example"})
c.AddIndex("test_save_idx", false, "example", "")
return c, nil
},
expectError: false,
expectColumns: []string{
"id", "email", "tokenKey",
"verified", "emailVisibility", "example",
},
},
{
name: "create view collection",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewViewCollection("new")
c.Fields.Add(&core.TextField{Name: "ignored"}) // should be ignored
c.ViewQuery = "select 1 as id, 2 as example"
return c, nil
},
expectError: false,
expectColumns: []string{
"id", "example",
},
},
// update
{
name: "update base collection",
collection: func(app core.App) (*core.Collection, error) {
c, _ := app.FindCollectionByNameOrId("demo5")
c.Fields.Add(&core.TextField{Name: "example"})
c.Fields.RemoveByName("file")
c.Fields.GetByName("total").SetName("total_updated")
c.AddIndex("test_save_idx", false, "example", "")
return c, nil
},
expectError: false,
expectColumns: []string{
"id", "select_one", "select_many", "rel_one", "rel_many",
"total_updated", "created", "updated", "example",
},
},
{
name: "update auth collection",
collection: func(app core.App) (*core.Collection, error) {
c, _ := app.FindCollectionByNameOrId("clients")
c.Fields.Add(&core.TextField{Name: "example"})
c.Fields.RemoveByName("file")
c.Fields.GetByName("name").SetName("name_updated")
c.AddIndex("test_save_idx", false, "example", "")
return c, nil
},
expectError: false,
expectColumns: []string{
"id", "email", "emailVisibility", "tokenKey",
"verified", "username", "name_updated", "created", "updated", "example",
},
},
{
name: "update view collection",
collection: func(app core.App) (*core.Collection, error) {
c, _ := app.FindCollectionByNameOrId("view2")
c.Fields.Add(&core.TextField{Name: "example"}) // should be ignored
c.ViewQuery = "select 1 as id, 2 as example"
return c, nil
},
expectError: false,
expectColumns: []string{
"id", "example",
},
},
// auth normalization
{
name: "unset missing oauth2 mapped fields",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewAuthCollection("new")
c.OAuth2.Enabled = true
// shouldn't fail
c.OAuth2.MappedFields = core.OAuth2KnownFields{
Id: "missing",
Name: "missing",
Username: "missing",
AvatarURL: "missing",
}
return c, nil
},
expectError: false,
expectColumns: []string{
"id", "email", "emailVisibility", "tokenKey", "verified",
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := s.collection(app)
if err != nil {
t.Fatalf("Failed to retrieve test collection: %v", err)
}
saveErr := app.Save(collection)
hasErr := saveErr != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", hasErr, s.expectError, saveErr)
}
if hasErr {
return
}
// the collection should always have an id after successful Save
if collection.Id == "" {
t.Fatal("Expected collection id to be set")
}
// the timestamp fields should be non-empty after successful Save
if collection.Created.String() == "" {
t.Fatal("Expected collection created to be set")
}
if collection.Updated.String() == "" {
t.Fatal("Expected collection updated to be set")
}
// check if the records table was synced
hasTable := app.HasTable(collection.Name)
if !hasTable {
t.Fatalf("Expected records table %s to be created", collection.Name)
}
// check if the records table has the fields fields
columns, err := app.TableColumns(collection.Name)
if err != nil {
t.Fatal(err)
}
if len(columns) != len(s.expectColumns) {
t.Fatalf("Expected columns\n%v\ngot\n%v", s.expectColumns, columns)
}
for i, c := range columns {
if !slices.Contains(s.expectColumns, c) {
t.Fatalf("[%d] Didn't expect record column %q", i, c)
}
}
// make sure that all collection indexes exists
indexes, err := app.TableIndexes(collection.Name)
if err != nil {
t.Fatal(err)
}
if len(indexes) != len(collection.Indexes) {
t.Fatalf("Expected %d indexes, got %d", len(collection.Indexes), len(indexes))
}
for _, idx := range collection.Indexes {
parsed := dbutils.ParseIndex(idx)
if _, ok := indexes[parsed.IndexName]; !ok {
t.Fatalf("Missing index %q in\n%v", idx, indexes)
}
}
})
}
}
// indirect update of a field used in view should cause view(s) update
func TestCollectionSaveIndirectViewsUpdate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
// update MaxSelect fields
{
relMany := collection.Fields.GetByName("rel_many").(*core.RelationField)
relMany.MaxSelect = 1
fileOne := collection.Fields.GetByName("file_one").(*core.FileField)
fileOne.MaxSelect = 10
if err := app.Save(collection); err != nil {
t.Fatal(err)
}
}
// check view1 fields
{
view1, err := app.FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
}
relMany := view1.Fields.GetByName("rel_many").(*core.RelationField)
if relMany.MaxSelect != 1 {
t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relMany.MaxSelect)
}
fileOne := view1.Fields.GetByName("file_one").(*core.FileField)
if fileOne.MaxSelect != 10 {
t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOne.MaxSelect)
}
}
// check view2 fields
{
view2, err := app.FindCollectionByNameOrId("view2")
if err != nil {
t.Fatal(err)
}
relMany := view2.Fields.GetByName("rel_many").(*core.RelationField)
if relMany.MaxSelect != 1 {
t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relMany.MaxSelect)
}
}
}
func TestCollectionSaveViewWrapping(t *testing.T) {
t.Parallel()
viewName := "test_wrapping"
scenarios := []struct {
name string
query string
expected string
}{
{
"no wrapping - text field",
"select text as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
},
{
"no wrapping - id field",
"select text as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
},
{
"no wrapping - relation field",
"select rel_one as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
},
{
"no wrapping - select field",
"select select_many as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
},
{
"no wrapping - email field",
"select email as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
},
{
"no wrapping - datetime field",
"select datetime as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
},
{
"no wrapping - url field",
"select url as id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
},
{
"wrapping - bool field",
"select bool as id, text as txt, url from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))",
},
{
"wrapping - bool field (different order)",
"select text as txt, url, bool as id from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,CAST(`id` as TEXT) `id` FROM (select text as txt, url, bool as id from demo1))",
},
{
"wrapping - json field",
"select json as id, text, url from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
},
{
"wrapping - numeric id",
"select 1 as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id` FROM (select 1 as id))",
},
{
"wrapping - expresion",
"select ('test') as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id` FROM (select ('test') as id))",
},
{
"no wrapping - cast as text",
"select cast('test' as text) as id",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select cast('test' as text) as id)",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := core.NewViewCollection(viewName)
collection.ViewQuery = s.query
err := app.Save(collection)
if err != nil {
t.Fatal(err)
}
var sql string
rowErr := app.ConcurrentDB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
Bind(dbx.Params{"name": viewName}).
Row(&sql)
if rowErr != nil {
t.Fatalf("Failed to retrieve view sql: %v", rowErr)
}
if sql != s.expected {
t.Fatalf("Expected query \n%v, \ngot \n%v", s.expected, sql)
}
})
}
}