From 6ac6ad9b361855df0ca99ce46fbf7a9c2d4e6204 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 09:42:58 -0700 Subject: [PATCH 1/2] test(view): put back the tag validations the label specs register The label specs call validatesPresenceOf() on a tag object, which registers on the shared tag class, so every later tag save in the run had to pass presence checks on name and virtual. A full-suite run's tag create was rejected for an empty virtual property and sent no INSERT. The label specs now restore the class's validations after each spec, and a check confirms the tag class ends the bundle with the validations it started with. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- vendor/wheels/tests/specs/view/formsSpec.cfc | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/vendor/wheels/tests/specs/view/formsSpec.cfc b/vendor/wheels/tests/specs/view/formsSpec.cfc index 9063933e28..7478dcf88b 100644 --- a/vendor/wheels/tests/specs/view/formsSpec.cfc +++ b/vendor/wheels/tests/specs/view/formsSpec.cfc @@ -9,6 +9,8 @@ component extends="wheels.WheelsTest" { * app's values back afterwards. */ function beforeAll() { + // The tag class's validations as configured, to check nothing below leaves one behind. + variables.tagValidationsAtStart = SerializeJSON(application.wo.model("tag").$classData().validations); variables.pinnedFormSettings = {includeFormErrorMessages = application.wheels.includeFormErrorMessages, labelPlacement = {}}; application.wheels.includeFormErrorMessages = false; for (var helperName in ListToArray("textField,textArea,passwordField,select,fileField,emailField,urlField,numberField,telField,dateField,searchField,colorField,rangeField")) { @@ -430,12 +432,17 @@ component extends="wheels.WheelsTest" { g.set(functionName = "textFieldTag", encode = false) // Reset tag model for BoxLang compatibility tagModel = g.model("tag") + // validatesPresenceOf() on a tag object registers on the shared tag class, so a later + // tag save anywhere in the run would fail it; put the class's validations back after. + savedTagValidations = Duplicate(tagModel.$classData().validations) }) afterEach(() => { g.set(functionName = "checkBoxTag", encode = true) g.set(functionName = "textField", encode = true) g.set(functionName = "textFieldTag", encode = true) + var tagClass = g.model("tag").$classData() + tagClass.validations = Duplicate(savedTagValidations) }) it("adds label to the left", () => { @@ -553,6 +560,13 @@ component extends="wheels.WheelsTest" { }) }) + describe("The tag class after the label specs", () => { + + it("keeps only the validations it was configured with", () => { + expect(SerializeJSON(g.model("tag").$classData().validations)).toBe(variables.tagValidationsAtStart) + }) + }) + describe("Tests that passwordField", () => { it("is valid", () => { From b79e87416d8967a96dc59ce3513c39e2c7e02c56 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 09:58:02 -0700 Subject: [PATCH 2/2] test(model): put back the class state three more specs change Three model specs register a validation or callback on a shared model class (or clear all its callbacks) and never put it back: - ModelHardenerM2M8Spec adds a uniqueness validation to post. - transactionsSpec adds an afterSave crashMe callback to tagWithDataCallbacks. - ModelSurfaceCoverageSpec cleared every post callback with $clearCallbacks(). Each now snapshots the class struct, restores it in a catch-free finally, and checks the class ends the bundle as it started. The checks, including the formsSpec one, compare a canonical form with struct keys sorted, so struct key order can't make them flaky. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .../specs/model/ModelSurfaceCoverageSpec.cfc | 35 +++++++++++++------ .../model/hardener/ModelHardenerM2M8Spec.cfc | 28 +++++++++++---- .../tests/specs/model/transactionsSpec.cfc | 25 ++++++++++--- vendor/wheels/tests/specs/view/formsSpec.cfc | 4 +-- 4 files changed, 67 insertions(+), 25 deletions(-) diff --git a/vendor/wheels/tests/specs/model/ModelSurfaceCoverageSpec.cfc b/vendor/wheels/tests/specs/model/ModelSurfaceCoverageSpec.cfc index 248d253a1c..c3df89ff03 100644 --- a/vendor/wheels/tests/specs/model/ModelSurfaceCoverageSpec.cfc +++ b/vendor/wheels/tests/specs/model/ModelSurfaceCoverageSpec.cfc @@ -11,6 +11,9 @@ component extends="wheels.WheelsTest" { */ function run() { + // The post class's callbacks before any spec here runs, for the no-leftover check below. + variables.postCallbacksAtStart = SerializeJSON(application.wo.$canonicalCacheValue(value = application.wo.model("post").$classData().callbacks)); + describe("model layer surfaces", () => { describe("ScopeChain terminal methods", () => { @@ -84,17 +87,27 @@ component extends="wheels.WheelsTest" { it("registers the remaining lifecycle callbacks and clears them", () => { var m = model("post"); - m.afterInitialization(); - m.afterNew(); - m.afterUpdate(); - m.afterValidation(); - m.afterValidationOnCreate(); - m.afterValidationOnUpdate(); - m.beforeUpdate(); - m.beforeValidationOnCreate(); - m.beforeValidationOnUpdate(); - // Revert so no phantom callback fires on later specs. - m.$clearCallbacks(); + // Put back exactly what the class had: $clearCallbacks() with no type would also + // drop callbacks the class was configured with. + var postClass = m.$classData(); + var savedCallbacks = Duplicate(postClass.callbacks); + try { + m.afterInitialization(); + m.afterNew(); + m.afterUpdate(); + m.afterValidation(); + m.afterValidationOnCreate(); + m.afterValidationOnUpdate(); + m.beforeUpdate(); + m.beforeValidationOnCreate(); + m.beforeValidationOnUpdate(); + } finally { + postClass.callbacks = savedCallbacks; + } + }); + + it("leaves the post class with only the callbacks it was configured with", () => { + expect(SerializeJSON(application.wo.$canonicalCacheValue(value = model("post").$classData().callbacks))).toBe(variables.postCallbacksAtStart); }); it("$coerceOracleTimestamp delegates to the engine adapter", () => { diff --git a/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc index 8fe2e6e806..a4607567fd 100644 --- a/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc +++ b/vendor/wheels/tests/specs/model/hardener/ModelHardenerM2M8Spec.cfc @@ -9,6 +9,8 @@ component extends="wheels.WheelsTest" { function run() { g = application.wo + // The post class's validations before any spec here runs, for the no-leftover check below. + variables.postValidationsAtStart = SerializeJSON(application.wo.$canonicalCacheValue(value = g.model("post").$classData().validations)); describe("M2 validatesUniquenessOf includeSoftDeletes default", () => { @@ -23,16 +25,28 @@ component extends="wheels.WheelsTest" { }); it("still treats a soft-deleted row as taken when includeSoftDeletes is true", () => { - transaction action="begin" { - var orgPost = g.model("post").findOne(); - var newPost = g.model("post").new(orgPost.properties()); - orgPost.delete(); - newPost.validatesUniquenessOf(properties = "title", includeSoftDeletes = true); - expect(newPost.valid()).toBeFalse(); - transaction action="rollback"; + // validatesUniquenessOf() on an object registers on the shared post class, so put the + // class's validations back afterwards or every later post save carries this one too. + var postClass = g.model("post").$classData(); + var savedValidations = Duplicate(postClass.validations); + try { + transaction action="begin" { + var orgPost = g.model("post").findOne(); + var newPost = g.model("post").new(orgPost.properties()); + orgPost.delete(); + newPost.validatesUniquenessOf(properties = "title", includeSoftDeletes = true); + expect(newPost.valid()).toBeFalse(); + transaction action="rollback"; + } + } finally { + postClass.validations = savedValidations; } }); + it("leaves the post class with only the validations it was configured with", () => { + expect(SerializeJSON(application.wo.$canonicalCacheValue(value = g.model("post").$classData().validations))).toBe(variables.postValidationsAtStart); + }); + }); describe("M3 QueryBuilder single-arg where is raw SQL", () => { diff --git a/vendor/wheels/tests/specs/model/transactionsSpec.cfc b/vendor/wheels/tests/specs/model/transactionsSpec.cfc index 0810c99767..4e4bccf672 100644 --- a/vendor/wheels/tests/specs/model/transactionsSpec.cfc +++ b/vendor/wheels/tests/specs/model/transactionsSpec.cfc @@ -5,6 +5,9 @@ component extends="wheels.WheelsTest" { g = application.wo var _isCockroachDB = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"; + // The tagWithDataCallbacks class's callbacks before any spec here runs, for the no-leftover check. + variables.tagCallbacksAtStart = SerializeJSON(application.wo.$canonicalCacheValue(value = g.model("tagWithDataCallbacks").$classData().callbacks)) + describe("Tests that invokewithtransaction", () => { beforeEach(() => { @@ -259,15 +262,27 @@ component extends="wheels.WheelsTest" { it("rollback when error raised", () => { if (_isCockroachDB) return; tagModel = g.model("tagWithDataCallbacks").new(name = "Kermit", description = "The Frog") - tagModel.afterSave(methods = "crashMe") + // afterSave() on an object registers on the shared class: put its callbacks back so + // later saves of this model don't run crashMe. + var tagClass = tagModel.$classData() + var savedCallbacks = Duplicate(tagClass.callbacks) try { - tagModel.save() - } catch (any e) { - results = g.model("tag").findAll(where = "name = 'Kermit'") + tagModel.afterSave(methods = "crashMe") + try { + tagModel.save() + } catch (any e) { + results = g.model("tag").findAll(where = "name = 'Kermit'") + } + } finally { + tagClass.callbacks = savedCallbacks } - + expect(results.recordcount).toBe(0) }) + + it("leaves the tagWithDataCallbacks class with only the callbacks it was configured with", () => { + expect(SerializeJSON(application.wo.$canonicalCacheValue(value = g.model("tagWithDataCallbacks").$classData().callbacks))).toBe(variables.tagCallbacksAtStart) + }) }) } } \ No newline at end of file diff --git a/vendor/wheels/tests/specs/view/formsSpec.cfc b/vendor/wheels/tests/specs/view/formsSpec.cfc index 7478dcf88b..afcd1f4a43 100644 --- a/vendor/wheels/tests/specs/view/formsSpec.cfc +++ b/vendor/wheels/tests/specs/view/formsSpec.cfc @@ -10,7 +10,7 @@ component extends="wheels.WheelsTest" { */ function beforeAll() { // The tag class's validations as configured, to check nothing below leaves one behind. - variables.tagValidationsAtStart = SerializeJSON(application.wo.model("tag").$classData().validations); + variables.tagValidationsAtStart = SerializeJSON(application.wo.$canonicalCacheValue(value = application.wo.model("tag").$classData().validations)); variables.pinnedFormSettings = {includeFormErrorMessages = application.wheels.includeFormErrorMessages, labelPlacement = {}}; application.wheels.includeFormErrorMessages = false; for (var helperName in ListToArray("textField,textArea,passwordField,select,fileField,emailField,urlField,numberField,telField,dateField,searchField,colorField,rangeField")) { @@ -563,7 +563,7 @@ component extends="wheels.WheelsTest" { describe("The tag class after the label specs", () => { it("keeps only the validations it was configured with", () => { - expect(SerializeJSON(g.model("tag").$classData().validations)).toBe(variables.tagValidationsAtStart) + expect(SerializeJSON(application.wo.$canonicalCacheValue(value = g.model("tag").$classData().validations))).toBe(variables.tagValidationsAtStart) }) })