package migratecmd_test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAutomigrateCollectionCreate(t *testing.T) {
t.Parallel()
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
///
migrate((app) => {
const collection = new Collection({
"authAlert": {
"emailTemplate": {
"body": "
Hello,
\nWe noticed a login to your {APP_NAME} account from a new location.
\nIf this was you, you may disregard this email.
\nIf this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "Hello,
\nClick on the button below to confirm your new email address.
\n\n Confirm new email\n
\nIf you didn't ask to change your email address, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
"deleteRule": null,
"emailChangeToken": {
"duration": 1800
},
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email@TEST_RANDOM",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
}
],
"fileToken": {
"duration": 180
},
"id": "@TEST_RANDOM",
"indexes": [
"create index test on new_name (id)",
"CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `new_name` + "`" + ` (` + "`" + `tokenKey` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `new_name` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"enabled": false,
"rule": ""
},
"name": "new_name",
"oauth2": {
"enabled": false,
"mappedFields": {
"avatarURL": "",
"id": "",
"name": "",
"username": ""
}
},
"otp": {
"duration": 180,
"emailTemplate": {
"body": "Hello,
\nYour one-time password is: {OTP}
\nIf you didn't ask for the one-time password, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "Hello,
\nClick on the button below to reset your password.
\n\n Reset password\n
\nIf you didn't ask to reset your password, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Reset your {APP_NAME} password"
},
"system": true,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "Hello,
\nThank you for joining us at {APP_NAME}.
\nClick on the button below to verify your email address.
\n\n Verify\n
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = \"1\""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("@TEST_RANDOM");
return app.delete(collection);
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := ` + "`" + `{
"authAlert": {
"emailTemplate": {
"body": "Hello,
\nWe noticed a login to your {APP_NAME} account from a new location.
\nIf this was you, you may disregard this email.
\nIf this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "Hello,
\nClick on the button below to confirm your new email address.
\n\n Confirm new email\n
\nIf you didn't ask to change your email address, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
"deleteRule": null,
"emailChangeToken": {
"duration": 1800
},
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email@TEST_RANDOM",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
}
],
"fileToken": {
"duration": 180
},
"id": "@TEST_RANDOM",
"indexes": [
"create index test on new_name (id)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `new_name` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `new_name` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"enabled": false,
"rule": ""
},
"name": "new_name",
"oauth2": {
"enabled": false,
"mappedFields": {
"avatarURL": "",
"id": "",
"name": "",
"username": ""
}
},
"otp": {
"duration": 180,
"emailTemplate": {
"body": "Hello,
\nYour one-time password is: {OTP}
\nIf you didn't ask for the one-time password, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "Hello,
\nClick on the button below to reset your password.
\n\n Reset password\n
\nIf you didn't ask to reset your password, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Reset your {APP_NAME} password"
},
"system": true,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "Hello,
\nThank you for joining us at {APP_NAME}.
\nClick on the button below to verify your email address.
\n\n Verify\n
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = \"1\""
}` + "`" + `
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM")
if err != nil {
return err
}
return app.Delete(collection)
})
}
`,
},
}
for _, s := range scenarios {
t.Run(s.lang, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
migratecmd.MustRegister(app, nil, migratecmd.Config{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
app.Bootstrap()
collection := core.NewAuthCollection("new_name")
collection.System = true
collection.ListRule = types.Pointer("@request.auth.id != '' && 1 > 0 || 'backtick`test' = 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.Indexes = types.JSONArray[string]{"create index test on new_name (id)"}
collection.ManageRule = types.Pointer("1 != 2")
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("a", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
collection.EmailChangeToken.Secret = testSecret
collection.PasswordResetToken.Secret = testSecret
collection.VerificationToken.Secret = testSecret
// save the newly created dummy collection (with mock request event)
event := new(core.CollectionRequestEvent)
event.RequestEvent = &core.RequestEvent{}
event.App = app
event.Collection = collection
err := app.OnCollectionCreateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.App.Save(e.Collection)
})
if err != nil {
t.Fatalf("Failed to save the created dummy collection, got: %v", err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("Expected migrationsDir to be created, got %v", err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 file to be generated, got %d: %v", total, files)
}
expectedName := "_created_new_name." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("Failed to read the generated migration file: %v", err)
}
contentStr := strings.TrimSpace(string(content))
// replace @TEST_RANDOM placeholder with a regex pattern
expectedTemplate := strings.ReplaceAll(
"^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$",
"@TEST_RANDOM",
`\w+`,
)
if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) {
t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr)
}
})
}
}
func TestAutomigrateCollectionDelete(t *testing.T) {
t.Parallel()
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
///
migrate((app) => {
const collection = app.findCollectionByNameOrId("@TEST_RANDOM");
return app.delete(collection);
}, (app) => {
const collection = new Collection({
"authAlert": {
"emailTemplate": {
"body": "Hello,
\nWe noticed a login to your {APP_NAME} account from a new location.
\nIf this was you, you may disregard this email.
\nIf this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "Hello,
\nClick on the button below to confirm your new email address.
\n\n Confirm new email\n
\nIf you didn't ask to change your email address, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
"deleteRule": null,
"emailChangeToken": {
"duration": 1800
},
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool256245529",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
}
],
"fileToken": {
"duration": 180
},
"id": "@TEST_RANDOM",
"indexes": [
"create index test on test123 (id)",
"CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `tokenKey` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"enabled": false,
"rule": ""
},
"name": "test123",
"oauth2": {
"enabled": false,
"mappedFields": {
"avatarURL": "",
"id": "",
"name": "",
"username": ""
}
},
"otp": {
"duration": 180,
"emailTemplate": {
"body": "Hello,
\nYour one-time password is: {OTP}
\nIf you didn't ask for the one-time password, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "Hello,
\nClick on the button below to reset your password.
\n\n Reset password\n
\nIf you didn't ask to reset your password, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Reset your {APP_NAME} password"
},
"system": false,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "Hello,
\nThank you for joining us at {APP_NAME}.
\nClick on the button below to verify your email address.
\n\n Verify\n
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = \"1\""
});
return app.save(collection);
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM")
if err != nil {
return err
}
return app.Delete(collection)
}, func(app core.App) error {
jsonData := ` + "`" + `{
"authAlert": {
"emailTemplate": {
"body": "Hello,
\nWe noticed a login to your {APP_NAME} account from a new location.
\nIf this was you, you may disregard this email.
\nIf this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "Hello,
\nClick on the button below to confirm your new email address.
\n\n Confirm new email\n
\nIf you didn't ask to change your email address, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
"deleteRule": null,
"emailChangeToken": {
"duration": 1800
},
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool256245529",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
}
],
"fileToken": {
"duration": 180
},
"id": "@TEST_RANDOM",
"indexes": [
"create index test on test123 (id)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"enabled": false,
"rule": ""
},
"name": "test123",
"oauth2": {
"enabled": false,
"mappedFields": {
"avatarURL": "",
"id": "",
"name": "",
"username": ""
}
},
"otp": {
"duration": 180,
"emailTemplate": {
"body": "Hello,
\nYour one-time password is: {OTP}
\nIf you didn't ask for the one-time password, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "Hello,
\nClick on the button below to reset your password.
\n\n Reset password\n
\nIf you didn't ask to reset your password, you can ignore this email.
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Reset your {APP_NAME} password"
},
"system": false,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "Hello,
\nThank you for joining us at {APP_NAME}.
\nClick on the button below to verify your email address.
\n\n Verify\n
\n\n Thanks,
\n {APP_NAME} team\n
",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = \"1\""
}` + "`" + `
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
})
}
`,
},
}
for _, s := range scenarios {
t.Run(s.lang, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
// create dummy collection
collection := core.NewAuthCollection("test123")
collection.ListRule = types.Pointer("@request.auth.id != '' && 1 > 0 || 'backtick`test' = 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.Indexes = types.JSONArray[string]{"create index test on test123 (id)"}
collection.ManageRule = types.Pointer("1 != 2")
if err := app.Save(collection); err != nil {
t.Fatalf("Failed to save dummy collection, got: %v", err)
}
migratecmd.MustRegister(app, nil, migratecmd.Config{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
app.Bootstrap()
// delete the newly created dummy collection (with mock request event)
event := new(core.CollectionRequestEvent)
event.RequestEvent = &core.RequestEvent{}
event.App = app
event.Collection = collection
err := app.OnCollectionDeleteRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.App.Delete(e.Collection)
})
if err != nil {
t.Fatalf("Failed to delete dummy collection, got: %v", err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("Expected migrationsDir to be created, got: %v", err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 file to be generated, got %d", total)
}
expectedName := "_deleted_test123." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("Failed to read the generated migration file: %v", err)
}
contentStr := strings.TrimSpace(string(content))
// replace @TEST_RANDOM placeholder with a regex pattern
expectedTemplate := strings.ReplaceAll(
"^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$",
"@TEST_RANDOM",
`\w+`,
)
if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) {
t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr)
}
})
}
}
func TestAutomigrateCollectionUpdate(t *testing.T) {
t.Parallel()
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
///
migrate((app) => {
const collection = app.findCollectionByNameOrId("@TEST_RANDOM")
// update collection data
unmarshal({
"createRule": "id = \"nil_update\"",
"deleteRule": null,
"fileToken": {
"duration": 10
},
"indexes": [
"create index test1 on test123_update (f1_name)",
"CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123_update` + "`" + ` (` + "`" + `tokenKey` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123_update` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''"
],
"listRule": "@request.auth.id != ''",
"name": "test123_update",
"oauth2": {
"enabled": true
},
"updateRule": "id = \"2_update\""
}, collection)
// remove field
collection.fields.removeById("f3_id")
// add field
collection.fields.addAt(8, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "f4_id",
"max": 0,
"min": 0,
"name": "f4_name",
"pattern": "` + "`" + `test backtick` + "`" + `123",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// update field
collection.fields.add(new Field({
"hidden": false,
"id": "f2_id",
"max": null,
"min": 10,
"name": "f2_name_new",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("@TEST_RANDOM")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": "id = \"3\"",
"fileToken": {
"duration": 180
},
"indexes": [
"create index test1 on test123 (f1_name)",
"CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `tokenKey` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 != 2",
"name": "test123",
"oauth2": {
"enabled": false
},
"updateRule": "id = \"2\""
}, collection)
// add field
collection.fields.addAt(8, new Field({
"hidden": false,
"id": "f3_id",
"name": "f3_name",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}))
// remove field
collection.fields.removeById("f4_id")
// update field
collection.fields.add(new Field({
"hidden": false,
"id": "f2_id",
"max": null,
"min": 10,
"name": "f2_name",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(` + "`" + `{
"createRule": "id = \"nil_update\"",
"deleteRule": null,
"fileToken": {
"duration": 10
},
"indexes": [
"create index test1 on test123_update (f1_name)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123_update` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123_update` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''"
],
"listRule": "@request.auth.id != ''",
"name": "test123_update",
"oauth2": {
"enabled": true
},
"updateRule": "id = \"2_update\""
}` + "`" + `), &collection); err != nil {
return err
}
// remove field
collection.Fields.RemoveById("f3_id")
// add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"autogeneratePattern": "",
"hidden": false,
"id": "f4_id",
"max": 0,
"min": 0,
"name": "f4_name",
"pattern": "` + "` + \"`\" + `" + `test backtick` + "` + \"`\" + `" + `123",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}` + "`" + `)); err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSON([]byte(` + "`" + `{
"hidden": false,
"id": "f2_id",
"max": null,
"min": 10,
"name": "f2_name_new",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}` + "`" + `)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(` + "`" + `{
"createRule": null,
"deleteRule": "id = \"3\"",
"fileToken": {
"duration": 180
},
"indexes": [
"create index test1 on test123 (f1_name)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 != 2",
"name": "test123",
"oauth2": {
"enabled": false
},
"updateRule": "id = \"2\""
}` + "`" + `), &collection); err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"hidden": false,
"id": "f3_id",
"name": "f3_name",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}` + "`" + `)); err != nil {
return err
}
// remove field
collection.Fields.RemoveById("f4_id")
// update field
if err := collection.Fields.AddMarshaledJSON([]byte(` + "`" + `{
"hidden": false,
"id": "f2_id",
"max": null,
"min": 10,
"name": "f2_name",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}` + "`" + `)); err != nil {
return err
}
return app.Save(collection)
})
}
`,
},
}
for _, s := range scenarios {
t.Run(s.lang, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
// create dummy collection
collection := core.NewAuthCollection("test123")
collection.ListRule = types.Pointer("@request.auth.id != '' && 1 != 2")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.UpdateRule = types.Pointer(`id = "2"`)
collection.CreateRule = nil
collection.DeleteRule = types.Pointer(`id = "3"`)
collection.Indexes = types.JSONArray[string]{"create index test1 on test123 (f1_name)"}
collection.ManageRule = types.Pointer("1 != 2")
collection.Fields.Add(&core.TextField{
Id: "f1_id",
Name: "f1_name",
Required: true,
})
collection.Fields.Add(&core.NumberField{
Id: "f2_id",
Name: "f2_name",
Min: types.Pointer(10.0),
})
collection.Fields.Add(&core.BoolField{
Id: "f3_id",
Name: "f3_name",
})
if err := app.Save(collection); err != nil {
t.Fatalf("Failed to save dummy collection, got %v", err)
}
// init plugin
migratecmd.MustRegister(app, nil, migratecmd.Config{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
app.Bootstrap()
// update the dummy collection
collection.Name = "test123_update"
collection.ListRule = types.Pointer("@request.auth.id != ''")
collection.ViewRule = types.Pointer(`id = "1"`) // no change
collection.UpdateRule = types.Pointer(`id = "2_update"`)
collection.CreateRule = types.Pointer(`id = "nil_update"`)
collection.DeleteRule = nil
collection.Indexes = types.JSONArray[string]{
"create index test1 on test123_update (f1_name)",
}
collection.Fields.RemoveById("f3_id")
collection.Fields.Add(&core.TextField{
Id: "f4_id",
Name: "f4_name",
Pattern: "`test backtick`123",
})
f := collection.Fields.GetById("f2_id")
f.SetName("f2_name_new")
collection.OAuth2.Enabled = true
collection.FileToken.Duration = 10
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("b", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
collection.EmailChangeToken.Secret = testSecret
collection.PasswordResetToken.Secret = testSecret
collection.VerificationToken.Secret = testSecret
// save the changes and trigger automigrate (with mock request event)
event := new(core.CollectionRequestEvent)
event.RequestEvent = &core.RequestEvent{}
event.App = app
event.Collection = collection
err := app.OnCollectionUpdateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.App.Save(e.Collection)
})
if err != nil {
t.Fatalf("Failed to save dummy collection changes, got %v", err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("Expected migrationsDir to be created, got: %v", err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 file to be generated, got %d", total)
}
expectedName := "_updated_test123." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("Failed to read the generated migration file: %v", err)
}
contentStr := strings.TrimSpace(string(content))
// replace @TEST_RANDOM placeholder with a regex pattern
expectedTemplate := strings.ReplaceAll(
"^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$",
"@TEST_RANDOM",
`\w+`,
)
if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) {
t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr)
}
})
}
}
func TestAutomigrateCollectionNoChanges(t *testing.T) {
t.Parallel()
scenarios := []struct {
lang string
}{
{
migratecmd.TemplateLangJS,
},
{
migratecmd.TemplateLangGo,
},
}
for _, s := range scenarios {
t.Run(s.lang, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
// create dummy collection
collection := core.NewAuthCollection("test123")
if err := app.Save(collection); err != nil {
t.Fatalf("Failed to save dummy collection, got %v", err)
}
// init plugin
migratecmd.MustRegister(app, nil, migratecmd.Config{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
app.Bootstrap()
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("b", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
collection.EmailChangeToken.Secret = testSecret
collection.PasswordResetToken.Secret = testSecret
collection.VerificationToken.Secret = testSecret
// resave without other changes and trigger automigrate (with mock request event)
event := new(core.CollectionRequestEvent)
event.RequestEvent = &core.RequestEvent{}
event.App = app
event.Collection = collection
err := app.OnCollectionUpdateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.App.Save(e.Collection)
})
if err != nil {
t.Fatalf("Failed to save dummy collection update, got %v", err)
}
files, _ := os.ReadDir(migrationsDir)
if total := len(files); total != 0 {
t.Fatalf("Expected 0 files to be generated, got %d", total)
}
})
}
}