diff --git a/vendor/wheels/tests/_assets/seeder/includethrow/seeds.cfm b/vendor/wheels/tests/_assets/seeder/includethrow/seeds.cfm new file mode 100644 index 0000000000..030a502468 --- /dev/null +++ b/vendor/wheels/tests/_assets/seeder/includethrow/seeds.cfm @@ -0,0 +1,9 @@ + + +seedOnce( + modelName = "author", + uniqueProperties = "firstName", + properties = {firstName: "SeederThrowOK99", lastName: "ThrowOK"} +); +throw(type = "SeederIncludeBoom", message = "intentional include throw"); + diff --git a/vendor/wheels/tests/_assets/seeder/seeds.cfm b/vendor/wheels/tests/_assets/seeder/seeds.cfm index 40581e0a27..f0e3ad66e3 100644 --- a/vendor/wheels/tests/_assets/seeder/seeds.cfm +++ b/vendor/wheels/tests/_assets/seeder/seeds.cfm @@ -1,5 +1,8 @@ - + -// This seed file is used by the test suite. -// It intentionally does nothing — tests that need seeded records use seedOnce() directly. +seedOnce( + modelName = "author", + uniqueProperties = "firstName", + properties = {firstName: "SeederMainOK99", lastName: "MainSeed"} +); diff --git a/vendor/wheels/tests/_assets/seeder/stray/seeds/orphaned.cfm b/vendor/wheels/tests/_assets/seeder/stray/seeds/orphaned.cfm new file mode 100644 index 0000000000..192255dceb --- /dev/null +++ b/vendor/wheels/tests/_assets/seeder/stray/seeds/orphaned.cfm @@ -0,0 +1,8 @@ + + +seedOnce( + modelName = "author", + uniqueProperties = "firstName", + properties = {firstName: "SeederStrayOrphan99", lastName: "StrayOrphan"} +); + diff --git a/vendor/wheels/tests/_assets/seeder/withenv/seeds.cfm b/vendor/wheels/tests/_assets/seeder/withenv/seeds.cfm index 10b8a2a452..06efd6ef95 100644 --- a/vendor/wheels/tests/_assets/seeder/withenv/seeds.cfm +++ b/vendor/wheels/tests/_assets/seeder/withenv/seeds.cfm @@ -1,4 +1,8 @@ - + -// Main seeds - runs before environment-specific seeds +seedOnce( + modelName = "author", + uniqueProperties = "firstName", + properties = {firstName: "SeederEnvMain99", lastName: "EnvMain"} +); diff --git a/vendor/wheels/tests/_assets/seeder/withenv/seeds/testing.cfm b/vendor/wheels/tests/_assets/seeder/withenv/seeds/testing.cfm index 06057043aa..b078cd0fef 100644 --- a/vendor/wheels/tests/_assets/seeder/withenv/seeds/testing.cfm +++ b/vendor/wheels/tests/_assets/seeder/withenv/seeds/testing.cfm @@ -1,4 +1,8 @@ - + -// Testing environment seeds - runs after main seeds.cfm +seedOnce( + modelName = "author", + uniqueProperties = "firstName", + properties = {firstName: "SeederEnvTest99", lastName: "EnvTest"} +); diff --git a/vendor/wheels/tests/specs/seederSpec.cfc b/vendor/wheels/tests/specs/seederSpec.cfc index f4ac163588..05f9893fda 100644 --- a/vendor/wheels/tests/specs/seederSpec.cfc +++ b/vendor/wheels/tests/specs/seederSpec.cfc @@ -1,3 +1,12 @@ +/** + * Seeder suite plus hardener proofs for desk IDs S1–S10. Desk IDs stay locked. + * + * HOLD (pin unflipped, current behavior only): + * S4 — seedOnce interpolates unique values into WHERE (quote-escape only). + * S5 — generateSeeds is not transactional (a mixed list can leave rows). + * + * This is the only seeder spec file. Do not invent wheels.tests.specs.seeder. + */ component extends="wheels.WheelsTest" { function beforeAll() { @@ -12,8 +21,10 @@ component extends="wheels.WheelsTest" { describe("init()", () => { - it("initializes with default seed path", () => { + it("S9: default seedPath is ExpandPath of /app/db/", () => { local.s = CreateObject("component", "wheels.Seeder").init(); + expect(local.s.seedMappingPath).toBe("/app/db/"); + expect(local.s.seedPath).toBe(ExpandPath("/app/db/")); expect(local.s.seedPath).toInclude("app"); }); @@ -39,6 +50,21 @@ component extends="wheels.WheelsTest" { expect(local.s.hasSeedFiles()).toBeFalse(); }); + it("S3: hasSeedFiles is true for a stray seeds/*.cfm that runSeeds will not include", () => { + // hasSeedFiles() returns true if ANY seeds/*.cfm exists. + // runSeeds() only includes seeds.cfm + seeds/.cfm. + local.s = CreateObject("component", "wheels.Seeder").init( + seedPath = "/wheels/tests/_assets/seeder/stray/" + ); + expect(local.s.hasSeedFiles()).toBeTrue(); + + $deleteAuthorByFirstName("SeederStrayOrphan99"); + local.result = local.s.runSeeds(environment = "testing"); + expect(local.result.success).toBeFalse(); + expect(local.result.message).toInclude("No seed files found"); + expect(IsObject(model("author").findOne(where = "firstName = 'SeederStrayOrphan99'"))).toBeFalse(); + }); + }); describe("runSeeds()", () => { @@ -52,21 +78,60 @@ component extends="wheels.WheelsTest" { expect(local.result.message).toInclude("No seed files found"); }); - it("runs main seeds.cfm file", () => { + it("S1: runs main seeds.cfm and creates the fixture record", () => { + $deleteAuthorByFirstName("SeederMainOK99"); + local.s = CreateObject("component", "wheels.Seeder").init( seedPath = "/wheels/tests/_assets/seeder/" ); local.result = local.s.runSeeds(environment = "testing"); + expect(local.result.success).toBeTrue(); - expect(local.result.totalCreated).toBeGTE(0); + expect(local.result.totalCreated).toBe(1); + expect(local.result.totalSkipped).toBe(0); + local.created = model("author").findOne(where = "firstName = 'SeederMainOK99'"); + expect(IsObject(local.created)).toBeTrue(); + expect(local.created.lastName).toBe("MainSeed"); + local.created.delete(); }); - it("includes environment-specific seeds when available", () => { + it("S2: includes environment-specific seeds when available", () => { + $deleteAuthorByFirstName("SeederEnvMain99"); + $deleteAuthorByFirstName("SeederEnvTest99"); + local.s = CreateObject("component", "wheels.Seeder").init( seedPath = "/wheels/tests/_assets/seeder/withenv/" ); local.result = local.s.runSeeds(environment = "testing"); + expect(local.result.success).toBeTrue(); + expect(local.result.totalCreated).toBe(2); + expect(local.result.environment).toBe("testing"); + local.mainRow = model("author").findOne(where = "firstName = 'SeederEnvMain99'"); + local.envRow = model("author").findOne(where = "firstName = 'SeederEnvTest99'"); + expect(IsObject(local.mainRow)).toBeTrue(); + expect(IsObject(local.envRow)).toBeTrue(); + expect(local.mainRow.lastName).toBe("EnvMain"); + expect(local.envRow.lastName).toBe("EnvTest"); + local.mainRow.delete(); + local.envRow.delete(); + }); + + it("S2: an environment without a matching file does not include seeds/testing.cfm", () => { + $deleteAuthorByFirstName("SeederEnvMain99"); + $deleteAuthorByFirstName("SeederEnvTest99"); + + local.s = CreateObject("component", "wheels.Seeder").init( + seedPath = "/wheels/tests/_assets/seeder/withenv/" + ); + local.result = local.s.runSeeds(environment = "development"); + + expect(local.result.success).toBeTrue(); + expect(local.result.totalCreated).toBe(1); + expect(local.result.environment).toBe("development"); + expect(IsObject(model("author").findOne(where = "firstName = 'SeederEnvMain99'"))).toBeTrue(); + expect(IsObject(model("author").findOne(where = "firstName = 'SeederEnvTest99'"))).toBeFalse(); + $deleteAuthorByFirstName("SeederEnvMain99"); }); it("returns failure and rolls back when a seedOnce entry fails validation", () => { @@ -93,6 +158,75 @@ component extends="wheels.WheelsTest" { expect(IsObject(local.leaked)).toBeFalse(); }); + it("S7: include-throw is caught, reported, and rolled back", () => { + $deleteAuthorByFirstName("SeederThrowOK99"); + + local.s = CreateObject("component", "wheels.Seeder").init( + seedPath = "/wheels/tests/_assets/seeder/includethrow/" + ); + local.result = local.s.runSeeds(environment = "testing"); + + expect(local.result.success).toBeFalse(); + expect(local.result.message).toInclude("Seed failed:"); + expect(local.result.message).toInclude("intentional include throw"); + expect(StructKeyExists(local.result, "detail")).toBeTrue(); + expect(IsObject(model("author").findOne(where = "firstName = 'SeederThrowOK99'"))).toBeFalse(); + }); + + it("S8: request.$wheelsSeeder is left set after a successful run", () => { + $deleteAuthorByFirstName("SeederMainOK99"); + StructDelete(request, "$wheelsSeeder"); + + local.s = CreateObject("component", "wheels.Seeder").init( + seedPath = "/wheels/tests/_assets/seeder/" + ); + local.result = local.s.runSeeds(environment = "testing"); + + expect(local.result.success).toBeTrue(); + expect(StructKeyExists(request, "$wheelsSeeder")).toBeTrue(); + expect(request.$wheelsSeeder.seedMappingPath).toBe("/wheels/tests/_assets/seeder/"); + $deleteAuthorByFirstName("SeederMainOK99"); + }); + + it("S8: a later run still sees request.$wheelsSeeder", () => { + $deleteAuthorByFirstName("SeederMainOK99"); + $deleteAuthorByFirstName("SeederEnvMain99"); + $deleteAuthorByFirstName("SeederEnvTest99"); + StructDelete(request, "$wheelsSeeder"); + + local.first = CreateObject("component", "wheels.Seeder").init( + seedPath = "/wheels/tests/_assets/seeder/" + ); + local.first.runSeeds(environment = "testing"); + expect(StructKeyExists(request, "$wheelsSeeder")).toBeTrue(); + + local.second = CreateObject("component", "wheels.Seeder").init( + seedPath = "/wheels/tests/_assets/seeder/withenv/" + ); + local.second.runSeeds(environment = "testing"); + + // Never cleared — a later run overwrites the leftover, it does not delete it. + expect(StructKeyExists(request, "$wheelsSeeder")).toBeTrue(); + expect(request.$wheelsSeeder.seedMappingPath).toBe("/wheels/tests/_assets/seeder/withenv/"); + $deleteAuthorByFirstName("SeederMainOK99"); + $deleteAuthorByFirstName("SeederEnvMain99"); + $deleteAuthorByFirstName("SeederEnvTest99"); + }); + + it("S8: the include-throw catch path also leaves request.$wheelsSeeder set", () => { + $deleteAuthorByFirstName("SeederThrowOK99"); + StructDelete(request, "$wheelsSeeder"); + + local.s = CreateObject("component", "wheels.Seeder").init( + seedPath = "/wheels/tests/_assets/seeder/includethrow/" + ); + local.result = local.s.runSeeds(environment = "testing"); + + expect(local.result.success).toBeFalse(); + expect(StructKeyExists(request, "$wheelsSeeder")).toBeTrue(); + expect(request.$wheelsSeeder.seedMappingPath).toBe("/wheels/tests/_assets/seeder/includethrow/"); + }); + it("throws when the environment name contains path traversal characters", () => { expect(function() { seeder.runSeeds(environment = "../../../app/somefile"); @@ -173,6 +307,42 @@ component extends="wheels.WheelsTest" { local.author.delete(); }); + it("S4 HOLD: unique values are interpolated into WHERE with quote-escape only", () => { + // HOLD pin: seedOnce builds `prop = 'escaped'` via + // Replace(val, "'", "''") and hands that string to findOne(where=). + // It does not bind parameters. After the query layer processes + // the interpolated clause, an apostrophe in the unique value + // does not match the stored row — the second call creates again. + // Do not flip to parameterized WHERE. + local.uniqueFirst = "O'Brien_#Replace(CreateUUID(), '-', '', 'all')#"; + + local.first = seeder.seedOnce( + modelName = "author", + uniqueProperties = "firstName", + properties = {firstName: local.uniqueFirst, lastName: "QuoteEscape"} + ); + expect(local.first.action).toBe("created"); + expect(StructKeyExists(local.first, "key")).toBeTrue(); + + local.second = seeder.seedOnce( + modelName = "author", + uniqueProperties = "firstName", + properties = {firstName: local.uniqueFirst, lastName: "QuoteEscape"} + ); + expect(local.second.action).toBe("created"); + expect(StructKeyExists(local.second, "key")).toBeTrue(); + expect(local.second.key).notToBe(local.first.key); + + local.firstRow = model("author").findByKey(local.first.key); + if (IsObject(local.firstRow)) { + local.firstRow.delete(); + } + local.secondRow = model("author").findByKey(local.second.key); + if (IsObject(local.secondRow)) { + local.secondRow.delete(); + } + }); + it("counts failed entries and reports them in the result", () => { seeder.totalFailed = 0; @@ -283,6 +453,78 @@ component extends="wheels.WheelsTest" { expect(StructKeyExists(local.result.seeded[1], "error")).toBeTrue(); }); + it("S5 HOLD: generateSeeds is not transactional — a mixed list can leave rows", () => { + // HOLD pin: generateSeeds has no transaction. Author rows persist + // even though the missing model forces overall success=false. + // Do not wrap generateSeeds in a transaction. + local.beforeQuery = model("Author").findAll(select = "id"); + local.beforeIds = ValueList(local.beforeQuery.id); + + local.gen = CreateObject("component", "wheels.Seeder").init(); + local.result = local.gen.generateSeeds( + models = "Author,NoSuchModel_SeederS5", + count = 1 + ); + + expect(local.result.success).toBeFalse(); + expect(local.result.totalCreated).toBe(1); + expect(local.result.totalFailed).toBe(1); + expect(ArrayLen(local.result.seeded)).toBe(2); + expect(local.result.seeded[1].model).toBe("Author"); + expect(local.result.seeded[1].success).toBeTrue(); + expect(local.result.seeded[2].success).toBeFalse(); + + local.afterQuery = model("Author").findAll(select = "id"); + local.afterIds = ValueList(local.afterQuery.id); + expect(ListLen(local.afterIds) - ListLen(local.beforeIds)).toBe(1); + + if (Len(local.beforeIds)) { + model("Author").deleteAll(where = "id NOT IN (#local.beforeIds#)", instantiate = false); + } else { + model("Author").deleteAll(instantiate = false); + } + }); + + it("S6: count=0 reports overall failure even when the per-model entry succeeds", () => { + // success requires totalCreated>0. The per-model loop does not + // run (1 <= 0), so seededCount==count and the entry is success=true. + local.beforeQuery = model("Author").findAll(select = "id"); + local.beforeIds = ValueList(local.beforeQuery.id); + + local.gen = CreateObject("component", "wheels.Seeder").init(); + local.result = local.gen.generateSeeds(models = "Author", count = 0); + + expect(local.result.success).toBeFalse(); + expect(local.result.totalCreated).toBe(0); + expect(local.result.totalFailed).toBe(0); + expect(ArrayLen(local.result.seeded)).toBe(1); + expect(local.result.seeded[1].count).toBe(0); + expect(local.result.seeded[1].success).toBeTrue(); + + local.afterQuery = model("Author").findAll(select = "id"); + expect(ValueList(local.afterQuery.id)).toBe(local.beforeIds); + }); + + it("S6: a negative count creates no rows and marks the model failed", () => { + // The for-loop does not run. seededCount (0) != count (-1), so + // the entry is success=false and totalFailed increments. + local.beforeQuery = model("Author").findAll(select = "id"); + local.beforeIds = ValueList(local.beforeQuery.id); + + local.gen = CreateObject("component", "wheels.Seeder").init(); + local.result = local.gen.generateSeeds(models = "Author", count = -1); + + expect(local.result.success).toBeFalse(); + expect(local.result.totalCreated).toBe(0); + expect(local.result.totalFailed).toBe(1); + expect(ArrayLen(local.result.seeded)).toBe(1); + expect(local.result.seeded[1].count).toBe(0); + expect(local.result.seeded[1].success).toBeFalse(); + + local.afterQuery = model("Author").findAll(select = "id"); + expect(ValueList(local.afterQuery.id)).toBe(local.beforeIds); + }); + it("reports failure (not silent success) when an explicit list resolves to no models", () => { local.gen = CreateObject("component", "wheels.Seeder").init(); // A delimiter-only list is a non-blank value (so it does NOT fall @@ -318,8 +560,114 @@ component extends="wheels.WheelsTest" { }); + describe("$generateTestData()", () => { + + it("S10: email names return an example.com address", () => { + expect(seeder.$generateTestData(propertyName = "email", propertyType = "string", index = 1)).toBe("test1@example.com"); + expect(seeder.$generateTestData(propertyName = "userEmail", propertyType = "integer", index = 4)).toBe("test4@example.com"); + }); + + it("S10: firstname / fname cycle the first-name list", () => { + expect(seeder.$generateTestData(propertyName = "firstName", propertyType = "string", index = 1)).toBe("John"); + expect(seeder.$generateTestData(propertyName = "fname", propertyType = "string", index = 2)).toBe("Jane"); + expect(seeder.$generateTestData(propertyName = "firstName", propertyType = "string", index = 11)).toBe("John"); + }); + + it("S10: lastname / lname cycle the last-name list", () => { + expect(seeder.$generateTestData(propertyName = "lastName", propertyType = "string", index = 1)).toBe("Smith"); + expect(seeder.$generateTestData(propertyName = "lname", propertyType = "string", index = 2)).toBe("Johnson"); + }); + + it("S10: exact name and username return TestUserN", () => { + expect(seeder.$generateTestData(propertyName = "name", propertyType = "string", index = 3)).toBe("TestUser3"); + expect(seeder.$generateTestData(propertyName = "userName", propertyType = "string", index = 3)).toBe("TestUser3"); + expect(seeder.$generateTestData(propertyName = "displayName", propertyType = "string", index = 3)).toBe("displayName Test 3"); + }); + + it("S10: phone / mobile, address / street, and geo names", () => { + expect(seeder.$generateTestData(propertyName = "phone", propertyType = "string", index = 1)).toBe("555-1001"); + expect(seeder.$generateTestData(propertyName = "mobile", propertyType = "string", index = 2)).toBe("555-1002"); + expect(seeder.$generateTestData(propertyName = "address", propertyType = "string", index = 4)).toBe("4 Test Street"); + expect(seeder.$generateTestData(propertyName = "street", propertyType = "string", index = 4)).toBe("4 Test Street"); + expect(seeder.$generateTestData(propertyName = "city", propertyType = "string", index = 1)).toBe("New York"); + expect(seeder.$generateTestData(propertyName = "city", propertyType = "string", index = 2)).toBe("Los Angeles"); + expect(seeder.$generateTestData(propertyName = "state", propertyType = "string", index = 1)).toBe("CA"); + expect(seeder.$generateTestData(propertyName = "province", propertyType = "string", index = 2)).toBe("TX"); + expect(seeder.$generateTestData(propertyName = "zip", propertyType = "string", index = 1)).toBe("10001"); + expect(seeder.$generateTestData(propertyName = "postal", propertyType = "string", index = 2)).toBe("10002"); + }); + + it("S10: url / website and password names", () => { + expect(seeder.$generateTestData(propertyName = "url", propertyType = "string", index = 5)).toBe("https://example5.com"); + expect(seeder.$generateTestData(propertyName = "website", propertyType = "string", index = 5)).toBe("https://example5.com"); + expect(seeder.$generateTestData(propertyName = "password", propertyType = "string", index = 5)).toBe("TestPass5!"); + }); + + it("S10: boolean type and active / enabled / published names", () => { + expect(seeder.$generateTestData(propertyName = "flag", propertyType = "boolean", index = 1)).toBeTrue(); + expect(seeder.$generateTestData(propertyName = "flag", propertyType = "boolean", index = 2)).toBeFalse(); + expect(seeder.$generateTestData(propertyName = "isActive", propertyType = "string", index = 1)).toBeTrue(); + expect(seeder.$generateTestData(propertyName = "enabled", propertyType = "string", index = 2)).toBeFalse(); + // publishedAt matches "published" before the later date-name branch. + expect(seeder.$generateTestData(propertyName = "publishedAt", propertyType = "string", index = 1)).toBeTrue(); + }); + + it("S10: integer / numeric type — age, price, quantity, and default", () => { + expect(seeder.$generateTestData(propertyName = "age", propertyType = "integer", index = 1)).toBe(21); + expect(seeder.$generateTestData(propertyName = "price", propertyType = "numeric", index = 1)).toBe(10.99); + expect(seeder.$generateTestData(propertyName = "cost", propertyType = "integer", index = 2)).toBe(20.99); + expect(seeder.$generateTestData(propertyName = "amount", propertyType = "integer", index = 3)).toBe(30.99); + expect(seeder.$generateTestData(propertyName = "quantity", propertyType = "integer", index = 2)).toBe(10); + expect(seeder.$generateTestData(propertyName = "count", propertyType = "integer", index = 3)).toBe(15); + expect(seeder.$generateTestData(propertyName = "views", propertyType = "integer", index = 7)).toBe(7); + }); + + it("S10: date type and date / birthday / dob names", () => { + local.typed = seeder.$generateTestData(propertyName = "foo", propertyType = "date", index = 3); + expect(IsDate(local.typed)).toBeTrue(); + expect(DateDiff("d", local.typed, Now())).toBe(3); + + local.named = seeder.$generateTestData(propertyName = "startDate", propertyType = "string", index = 2); + expect(IsDate(local.named)).toBeTrue(); + expect(DateDiff("d", local.named, Now())).toBe(2); + + local.birthday = seeder.$generateTestData(propertyName = "birthday", propertyType = "string", index = 1); + expect(IsDate(local.birthday)).toBeTrue(); + local.dob = seeder.$generateTestData(propertyName = "dob", propertyType = "datetime", index = 4); + expect(IsDate(local.dob)).toBeTrue(); + expect(DateDiff("d", local.dob, Now())).toBe(4); + }); + + it("S10: text type and description / content / body / title / status", () => { + local.lorem = "This is test content 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."; + expect(seeder.$generateTestData(propertyName = "foo", propertyType = "text", index = 1)).toBe(local.lorem); + expect(seeder.$generateTestData(propertyName = "description", propertyType = "string", index = 1)).toBe(local.lorem); + expect(seeder.$generateTestData(propertyName = "content", propertyType = "string", index = 1)).toBe(local.lorem); + expect(seeder.$generateTestData(propertyName = "body", propertyType = "string", index = 1)).toBe(local.lorem); + expect(seeder.$generateTestData(propertyName = "title", propertyType = "string", index = 8)).toBe("Test Title 8"); + expect(seeder.$generateTestData(propertyName = "subject", propertyType = "string", index = 8)).toBe("Test Title 8"); + expect(seeder.$generateTestData(propertyName = "status", propertyType = "string", index = 1)).toBe("pending"); + expect(seeder.$generateTestData(propertyName = "status", propertyType = "string", index = 2)).toBe("active"); + expect(seeder.$generateTestData(propertyName = "status", propertyType = "string", index = 5)).toBe("pending"); + }); + + it("S10: unmatched names fall through to the default string", () => { + expect(seeder.$generateTestData(propertyName = "zzz", propertyType = "string", index = 9)).toBe("zzz Test 9"); + expect(seeder.$generateTestData(propertyName = "notes", index = 1)).toBe("notes Test 1"); + }); + + }); + }); } + public void function $deleteAuthorByFirstName(required string firstName) { + local.escaped = Replace(arguments.firstName, "'", "''", "all"); + local.record = model("author").findOne(where = "firstName = '#local.escaped#'"); + if (IsObject(local.record)) { + local.record.delete(); + } + } + }