From 918a4fab8ff3c3ef45005435bc4891f67d43e84c Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 17 Apr 2026 14:13:08 +0200 Subject: [PATCH 1/4] TESTBOX-448 normalise nested struct args for order-independent matching --- system/MockBox.cfc | 49 ++++++++++++++++++++++++----- tests/specs/mockbox/MockBoxTest.cfc | 44 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/system/MockBox.cfc b/system/MockBox.cfc index 790a6060..86ad3d42 100644 --- a/system/MockBox.cfc +++ b/system/MockBox.cfc @@ -640,13 +640,8 @@ component accessors=true { // If an object and a class, just use serializeJSON serializedArgs &= serializeJSON( getMetadata( argOrderedTree[ arg ] ) ); } else { - // Get obj rep - try { - serializedArgs &= argOrderedTree[ arg ].toString(); - } catch ( any e ) { - // Fallback - serializedArgs &= serializeJSON( argOrderedTree[ arg ] ); - } + // Deterministic string representation - struct key iteration order is not guaranteed + serializedArgs &= normalizeValue( argOrderedTree[ arg ] ); } } /* ColdFusion isn't case sensitive, so case of string values shouldn't matter. We do it after serializing all args @@ -655,6 +650,46 @@ component accessors=true { return hash( lCase( serializedArgs ) ); } + /** + * Produce a deterministic string representation of a value for use in argument hashing. + * Structs are sorted by key so iteration order (HashMap bucket layout) does not affect + * the resulting hash. Arrays preserve position but recurse into each element. + * + * @value The value to serialize + */ + private function normalizeValue( required any value ){ + // Simple value - common case first + if ( isSimpleValue( arguments.value ) ) { + return toString( arguments.value ); + } + // Struct (but not a CFC - those use metadata via the caller) + if ( isStruct( arguments.value ) && !isObject( arguments.value ) ) { + var sorted = createObject( "java", "java.util.TreeMap" ).init( arguments.value ); + var parts = []; + for ( var key in sorted ) { + arrayAppend( + parts, + key & "=" & ( isNull( sorted[ key ] ) ? "null" : normalizeValue( sorted[ key ] ) ) + ); + } + return "{" & arrayToList( parts, "," ) & "}"; + } + // Array - order is semantically meaningful, so preserve it but recurse + if ( isArray( arguments.value ) ) { + var parts = []; + for ( var item in arguments.value ) { + arrayAppend( parts, isNull( item ) ? "null" : normalizeValue( item ) ); + } + return "[" & arrayToList( parts, "," ) & "]"; + } + // Fallback: Java object, query, etc + try { + return arguments.value.toString(); + } catch ( any e ) { + return serializeJSON( arguments.value ); + } + } + /** * Decorate a mock object with all the necessary methods and properties * diff --git a/tests/specs/mockbox/MockBoxTest.cfc b/tests/specs/mockbox/MockBoxTest.cfc index bf50e148..f2dac5e6 100755 --- a/tests/specs/mockbox/MockBoxTest.cfc +++ b/tests/specs/mockbox/MockBoxTest.cfc @@ -393,6 +393,50 @@ $assert.isEqual( "UnitTest3", results ); } + // Regression: struct args with the same contents but different insertion order + // must match. normalizeArguments() used to serialize nested structs via + // struct.toString(), which is iteration-order dependent, so arg matching broke + // whenever the struct under test was built in a different order than the mock. + // Surfaced by Preside tests on Lucee 7.1 (LDEV-5098 changed bucket layout). + function testMockArgsStructOrderIndependence(){ + var service = getMockBox().createStub(); + + // Build the expected struct in one insertion order (linked = guaranteed order) + var expectedArgs = structNew( "linked" ); + expectedArgs.foo = "one"; + expectedArgs.bar = "two"; + expectedArgs.baz = "three"; + + service.$( "save" ).$args( data=expectedArgs ).$results( "matched" ); + + // Structurally equal, built in a different insertion order + var actualArgs = structNew( "linked" ); + actualArgs.baz = "three"; + actualArgs.foo = "one"; + actualArgs.bar = "two"; + + $assert.isEqual( "matched", service.save( data=actualArgs ) ); + + // Nested struct: inner struct key order must not matter either + var expectedNested = structNew( "linked" ); + expectedNested.outerA = "1"; + expectedNested.inner = structNew( "linked" ); + expectedNested.inner.a = 1; + expectedNested.inner.b = 2; + expectedNested.outerZ = "9"; + + service.$( "persist" ).$args( payload=expectedNested ).$results( "nested-matched" ); + + var actualNested = structNew( "linked" ); + actualNested.outerZ = "9"; + actualNested.inner = structNew( "linked" ); + actualNested.inner.b = 2; + actualNested.inner.a = 1; + actualNested.outerA = "1"; + + $assert.isEqual( "nested-matched", service.persist( payload=actualNested ) ); + } + function testGetProperty(){ mock = getMockBox().createStub(); mock.luis = "Majano"; From 75f8c273bd956fb2037a74f66d29a4dcf9eb50d1 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 17 Apr 2026 14:23:03 +0200 Subject: [PATCH 2/4] TESTBOX-448 fix formatting --- tests/specs/mockbox/MockBoxTest.cfc | 46 ++++++++++++++--------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/specs/mockbox/MockBoxTest.cfc b/tests/specs/mockbox/MockBoxTest.cfc index f2dac5e6..f85911d6 100755 --- a/tests/specs/mockbox/MockBoxTest.cfc +++ b/tests/specs/mockbox/MockBoxTest.cfc @@ -402,12 +402,12 @@ var service = getMockBox().createStub(); // Build the expected struct in one insertion order (linked = guaranteed order) - var expectedArgs = structNew( "linked" ); - expectedArgs.foo = "one"; - expectedArgs.bar = "two"; - expectedArgs.baz = "three"; + var expectedArgs = structNew( "linked" ); + expectedArgs.foo = "one"; + expectedArgs.bar = "two"; + expectedArgs.baz = "three"; - service.$( "save" ).$args( data=expectedArgs ).$results( "matched" ); + service.$( "save" ).$args( data = expectedArgs ).$results( "matched" ); // Structurally equal, built in a different insertion order var actualArgs = structNew( "linked" ); @@ -415,26 +415,26 @@ actualArgs.foo = "one"; actualArgs.bar = "two"; - $assert.isEqual( "matched", service.save( data=actualArgs ) ); + $assert.isEqual( "matched", service.save( data = actualArgs ) ); // Nested struct: inner struct key order must not matter either - var expectedNested = structNew( "linked" ); - expectedNested.outerA = "1"; - expectedNested.inner = structNew( "linked" ); - expectedNested.inner.a = 1; - expectedNested.inner.b = 2; - expectedNested.outerZ = "9"; - - service.$( "persist" ).$args( payload=expectedNested ).$results( "nested-matched" ); - - var actualNested = structNew( "linked" ); - actualNested.outerZ = "9"; - actualNested.inner = structNew( "linked" ); - actualNested.inner.b = 2; - actualNested.inner.a = 1; - actualNested.outerA = "1"; - - $assert.isEqual( "nested-matched", service.persist( payload=actualNested ) ); + var expectedNested = structNew( "linked" ); + expectedNested.outerA = "1"; + expectedNested.inner = structNew( "linked" ); + expectedNested.inner.a = 1; + expectedNested.inner.b = 2; + expectedNested.outerZ = "9"; + + service.$( "persist" ).$args( payload = expectedNested ).$results( "nested-matched" ); + + var actualNested = structNew( "linked" ); + actualNested.outerZ = "9"; + actualNested.inner = structNew( "linked" ); + actualNested.inner.b = 2; + actualNested.inner.a = 1; + actualNested.outerA = "1"; + + $assert.isEqual( "nested-matched", service.persist( payload = actualNested ) ); } function testGetProperty(){ From 8dc14032f6fa773b62abc3efa69b6369e308791a Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 17 Apr 2026 14:33:46 +0200 Subject: [PATCH 3/4] TESTBOX-448 handle CFCs in normalizeValue and add nested-arg tests --- system/MockBox.cfc | 25 +++++++++++---- tests/specs/mockbox/MockBoxTest.cfc | 50 ++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/system/MockBox.cfc b/system/MockBox.cfc index 86ad3d42..c442ebb0 100644 --- a/system/MockBox.cfc +++ b/system/MockBox.cfc @@ -651,18 +651,29 @@ component accessors=true { } /** - * Produce a deterministic string representation of a value for use in argument hashing. - * Structs are sorted by key so iteration order (HashMap bucket layout) does not affect - * the resulting hash. Arrays preserve position but recurse into each element. + * Deterministic string representation of a value for argument hashing. + * Struct keys are sorted so iteration order does not affect the hash. * * @value The value to serialize */ private function normalizeValue( required any value ){ - // Simple value - common case first + // Simple value if ( isSimpleValue( arguments.value ) ) { return toString( arguments.value ); } - // Struct (but not a CFC - those use metadata via the caller) + // CFC - must check before struct; CFCs are isStruct+isObject on Adobe + if ( + isObject( arguments.value ) and + ( + isInstanceOf( arguments.value, "Component" ) or structKeyExists( + getMetadata( arguments.value ), + "extends" + ) + ) + ) { + return serializeJSON( getMetadata( arguments.value ) ); + } + // Struct - sort keys if ( isStruct( arguments.value ) && !isObject( arguments.value ) ) { var sorted = createObject( "java", "java.util.TreeMap" ).init( arguments.value ); var parts = []; @@ -674,7 +685,7 @@ component accessors=true { } return "{" & arrayToList( parts, "," ) & "}"; } - // Array - order is semantically meaningful, so preserve it but recurse + // Array - keep order, recurse if ( isArray( arguments.value ) ) { var parts = []; for ( var item in arguments.value ) { @@ -682,7 +693,7 @@ component accessors=true { } return "[" & arrayToList( parts, "," ) & "]"; } - // Fallback: Java object, query, etc + // Fallback try { return arguments.value.toString(); } catch ( any e ) { diff --git a/tests/specs/mockbox/MockBoxTest.cfc b/tests/specs/mockbox/MockBoxTest.cfc index f85911d6..f6965efb 100755 --- a/tests/specs/mockbox/MockBoxTest.cfc +++ b/tests/specs/mockbox/MockBoxTest.cfc @@ -393,15 +393,10 @@ $assert.isEqual( "UnitTest3", results ); } - // Regression: struct args with the same contents but different insertion order - // must match. normalizeArguments() used to serialize nested structs via - // struct.toString(), which is iteration-order dependent, so arg matching broke - // whenever the struct under test was built in a different order than the mock. - // Surfaced by Preside tests on Lucee 7.1 (LDEV-5098 changed bucket layout). + // Struct args must match regardless of insertion order (TESTBOX-448) function testMockArgsStructOrderIndependence(){ var service = getMockBox().createStub(); - // Build the expected struct in one insertion order (linked = guaranteed order) var expectedArgs = structNew( "linked" ); expectedArgs.foo = "one"; expectedArgs.bar = "two"; @@ -409,7 +404,6 @@ service.$( "save" ).$args( data = expectedArgs ).$results( "matched" ); - // Structurally equal, built in a different insertion order var actualArgs = structNew( "linked" ); actualArgs.baz = "three"; actualArgs.foo = "one"; @@ -417,7 +411,7 @@ $assert.isEqual( "matched", service.save( data = actualArgs ) ); - // Nested struct: inner struct key order must not matter either + // Nested struct: inner key order must not matter either var expectedNested = structNew( "linked" ); expectedNested.outerA = "1"; expectedNested.inner = structNew( "linked" ); @@ -437,6 +431,46 @@ $assert.isEqual( "nested-matched", service.persist( payload = actualNested ) ); } + // Struct args containing a CFC must not trigger Adobe's JSON-serializer cycle + function testMockArgsStructContainingCFC(){ + var service = getMockBox().createStub(); + + var expected = structNew( "linked" ); + expected.id = 42; + expected.ref = getMockBox().createStub(); + + service.$( "save" ).$args( data = expected ).$results( "ok" ); + + var actual = structNew( "linked" ); + actual.ref = getMockBox().createStub(); + actual.id = 42; + + $assert.isEqual( "ok", service.save( data = actual ) ); + } + + // Deep nesting: struct > array > struct must canonicalise all the way down + function testMockArgsDeepNesting(){ + var service = getMockBox().createStub(); + + var expected = structNew( "linked" ); + expected.outer = "z"; + expected.items = []; + arrayAppend( expected.items, { a: 1, b: 2 } ); + arrayAppend( expected.items, { a: 3, b: 4 } ); + expected.another = "y"; + + service.$( "process" ).$args( payload = expected ).$results( "deep" ); + + var actual = structNew( "linked" ); + actual.another = "y"; + actual.items = []; + arrayAppend( actual.items, { b: 2, a: 1 } ); + arrayAppend( actual.items, { b: 4, a: 3 } ); + actual.outer = "z"; + + $assert.isEqual( "deep", service.process( payload = actual ) ); + } + function testGetProperty(){ mock = getMockBox().createStub(); mock.luis = "Majano"; From d95c65aff1ebf91d8e6f1c6436f079cc1965502e Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 17 Apr 2026 14:45:55 +0200 Subject: [PATCH 4/4] TESTBOX-448 use structNew(ordered) for Adobe compat and apply cfformat --- tests/specs/mockbox/MockBoxTest.cfc | 56 +++++++++++++++++------------ 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/tests/specs/mockbox/MockBoxTest.cfc b/tests/specs/mockbox/MockBoxTest.cfc index f6965efb..323c6d78 100755 --- a/tests/specs/mockbox/MockBoxTest.cfc +++ b/tests/specs/mockbox/MockBoxTest.cfc @@ -397,14 +397,17 @@ function testMockArgsStructOrderIndependence(){ var service = getMockBox().createStub(); - var expectedArgs = structNew( "linked" ); + var expectedArgs = structNew( "ordered" ); expectedArgs.foo = "one"; expectedArgs.bar = "two"; expectedArgs.baz = "three"; - service.$( "save" ).$args( data = expectedArgs ).$results( "matched" ); + service + .$( "save" ) + .$args( data = expectedArgs ) + .$results( "matched" ); - var actualArgs = structNew( "linked" ); + var actualArgs = structNew( "ordered" ); actualArgs.baz = "three"; actualArgs.foo = "one"; actualArgs.bar = "two"; @@ -412,18 +415,21 @@ $assert.isEqual( "matched", service.save( data = actualArgs ) ); // Nested struct: inner key order must not matter either - var expectedNested = structNew( "linked" ); + var expectedNested = structNew( "ordered" ); expectedNested.outerA = "1"; - expectedNested.inner = structNew( "linked" ); + expectedNested.inner = structNew( "ordered" ); expectedNested.inner.a = 1; expectedNested.inner.b = 2; expectedNested.outerZ = "9"; - service.$( "persist" ).$args( payload = expectedNested ).$results( "nested-matched" ); + service + .$( "persist" ) + .$args( payload = expectedNested ) + .$results( "nested-matched" ); - var actualNested = structNew( "linked" ); + var actualNested = structNew( "ordered" ); actualNested.outerZ = "9"; - actualNested.inner = structNew( "linked" ); + actualNested.inner = structNew( "ordered" ); actualNested.inner.b = 2; actualNested.inner.a = 1; actualNested.outerA = "1"; @@ -435,13 +441,16 @@ function testMockArgsStructContainingCFC(){ var service = getMockBox().createStub(); - var expected = structNew( "linked" ); + var expected = structNew( "ordered" ); expected.id = 42; expected.ref = getMockBox().createStub(); - service.$( "save" ).$args( data = expected ).$results( "ok" ); + service + .$( "save" ) + .$args( data = expected ) + .$results( "ok" ); - var actual = structNew( "linked" ); + var actual = structNew( "ordered" ); actual.ref = getMockBox().createStub(); actual.id = 42; @@ -452,21 +461,24 @@ function testMockArgsDeepNesting(){ var service = getMockBox().createStub(); - var expected = structNew( "linked" ); - expected.outer = "z"; - expected.items = []; - arrayAppend( expected.items, { a: 1, b: 2 } ); - arrayAppend( expected.items, { a: 3, b: 4 } ); - expected.another = "y"; + var expected = structNew( "ordered" ); + expected.outer = "z"; + expected.items = []; + arrayAppend( expected.items, { a : 1, b : 2 } ); + arrayAppend( expected.items, { a : 3, b : 4 } ); + expected.another = "y"; - service.$( "process" ).$args( payload = expected ).$results( "deep" ); + service + .$( "process" ) + .$args( payload = expected ) + .$results( "deep" ); - var actual = structNew( "linked" ); + var actual = structNew( "ordered" ); actual.another = "y"; actual.items = []; - arrayAppend( actual.items, { b: 2, a: 1 } ); - arrayAppend( actual.items, { b: 4, a: 3 } ); - actual.outer = "z"; + arrayAppend( actual.items, { b : 2, a : 1 } ); + arrayAppend( actual.items, { b : 4, a : 3 } ); + actual.outer = "z"; $assert.isEqual( "deep", service.process( payload = actual ) ); }