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,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If 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,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If 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,

\n

Your one-time password is: {OTP}

\n

If 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,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If 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,

\n

Thank you for joining us at {APP_NAME}.

\n

Click 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,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If 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,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If 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,

\n

Your one-time password is: {OTP}

\n

If 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,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If 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,

\n

Thank you for joining us at {APP_NAME}.

\n

Click 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,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If 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,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If 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,

\n

Your one-time password is: {OTP}

\n

If 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,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If 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,

\n

Thank you for joining us at {APP_NAME}.

\n

Click 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,

\n

We noticed a login to your {APP_NAME} account from a new location.

\n

If this was you, you may disregard this email.

\n

If 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,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If 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,

\n

Your one-time password is: {OTP}

\n

If 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,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If 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,

\n

Thank you for joining us at {APP_NAME}.

\n

Click 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) } }) } }