diff --git a/system/MockBox.cfc b/system/MockBox.cfc index 790a6060..c442ebb0 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,57 @@ component accessors=true { return hash( lCase( serializedArgs ) ); } + /** + * 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 + if ( isSimpleValue( arguments.value ) ) { + return toString( arguments.value ); + } + // 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 = []; + for ( var key in sorted ) { + arrayAppend( + parts, + key & "=" & ( isNull( sorted[ key ] ) ? "null" : normalizeValue( sorted[ key ] ) ) + ); + } + return "{" & arrayToList( parts, "," ) & "}"; + } + // Array - keep order, recurse + if ( isArray( arguments.value ) ) { + var parts = []; + for ( var item in arguments.value ) { + arrayAppend( parts, isNull( item ) ? "null" : normalizeValue( item ) ); + } + return "[" & arrayToList( parts, "," ) & "]"; + } + // Fallback + 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..323c6d78 100755 --- a/tests/specs/mockbox/MockBoxTest.cfc +++ b/tests/specs/mockbox/MockBoxTest.cfc @@ -393,6 +393,96 @@ $assert.isEqual( "UnitTest3", results ); } + // Struct args must match regardless of insertion order (TESTBOX-448) + function testMockArgsStructOrderIndependence(){ + var service = getMockBox().createStub(); + + var expectedArgs = structNew( "ordered" ); + expectedArgs.foo = "one"; + expectedArgs.bar = "two"; + expectedArgs.baz = "three"; + + service + .$( "save" ) + .$args( data = expectedArgs ) + .$results( "matched" ); + + var actualArgs = structNew( "ordered" ); + actualArgs.baz = "three"; + actualArgs.foo = "one"; + actualArgs.bar = "two"; + + $assert.isEqual( "matched", service.save( data = actualArgs ) ); + + // Nested struct: inner key order must not matter either + var expectedNested = structNew( "ordered" ); + expectedNested.outerA = "1"; + expectedNested.inner = structNew( "ordered" ); + expectedNested.inner.a = 1; + expectedNested.inner.b = 2; + expectedNested.outerZ = "9"; + + service + .$( "persist" ) + .$args( payload = expectedNested ) + .$results( "nested-matched" ); + + var actualNested = structNew( "ordered" ); + actualNested.outerZ = "9"; + actualNested.inner = structNew( "ordered" ); + actualNested.inner.b = 2; + actualNested.inner.a = 1; + actualNested.outerA = "1"; + + $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( "ordered" ); + expected.id = 42; + expected.ref = getMockBox().createStub(); + + service + .$( "save" ) + .$args( data = expected ) + .$results( "ok" ); + + var actual = structNew( "ordered" ); + 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( "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" ); + + 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"; + + $assert.isEqual( "deep", service.process( payload = actual ) ); + } + function testGetProperty(){ mock = getMockBox().createStub(); mock.luis = "Majano";