Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 81 additions & 7 deletions system/MockBox.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ component accessors=true {
property name="mockGenerator";
property name="generationPath";

// Used to gate BoxLang-only type checks (Set/Range) in normalizeValue() so they're
// never attempted on engines where those BIFs don't exist at all (Lucee/Adobe).
variables.IS_BOXLANG = server.keyExists( "boxlang" );

/**
* Create an instance of MockBox
*
Expand Down Expand Up @@ -640,13 +644,9 @@ 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/set key and iteration order
// is not guaranteed to match between two structurally-equal values
serializedArgs &= normalizeValue( argOrderedTree[ arg ] );
}
}
/* ColdFusion isn't case sensitive, so case of string values shouldn't matter. We do it after serializing all args
Expand All @@ -655,6 +655,80 @@ 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. Composite
* values are JSON-encoded (rather than hand-joined with bare delimiters) so that
* delimiter-like characters inside string values can't cause two structurally
* different arguments to normalize to the same string.
*
* @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 ) );
}
// BoxLang Range - must check before isArray(), since isArray() is TRUE for a Range.
// Canonicalize via toString(), which fully captures bounds/step/exclusivity
// ("1..5" vs "1>..<5" vs "1..10.step(3)" all differ) - never iterate/materialize
// the range, since it may be huge or unbounded (an open-start range even throws
// if you try to iterate it). Only ever checked on BoxLang, where isRange() exists.
if ( variables.IS_BOXLANG && isRange( arguments.value ) ) {
return "range:" & arguments.value.toString();
}
// BoxLang Set - falls through every other check (isStruct/isArray/isObject are
// all false for a Set). Sets are unordered by definition - even same-content
// linked/sorted sets can iterate differently depending on how they were built -
// so normalize each element, then sort the normalized representations, exactly
// like the struct-key sort below, before JSON-encoding. Type-tagged (like Range
// above) so a Set can never collide with an Array holding the same elements.
if ( variables.IS_BOXLANG && isBoxSet( arguments.value ) ) {
var setParts = [];
for ( var item in arguments.value.toArray() ) {
arrayAppend( setParts, isNull( item ) ? "null" : normalizeValue( item ) );
}
arraySort( setParts, "textnocase" );
return "set:" & serializeJSON( setParts );
}
// Struct - sort keys, recurse, then JSON-encode (escapes any delimiter-like
// characters inside values so structurally different args can't collide)
if ( isStruct( arguments.value ) && !isObject( arguments.value ) ) {
var sorted = createObject( "java", "java.util.TreeMap" ).init( arguments.value );
var canonical = structNew( "ordered" );
for ( var key in sorted ) {
canonical[ key ] = isNull( sorted[ key ] ) ? "null" : normalizeValue( sorted[ key ] );
}
return serializeJSON( canonical );
}
// Array - keep order, recurse, then JSON-encode
if ( isArray( arguments.value ) ) {
var canonical = [];
for ( var item in arguments.value ) {
arrayAppend( canonical, isNull( item ) ? "null" : normalizeValue( item ) );
}
return serializeJSON( canonical );
}
// Fallback
try {
return arguments.value.toString();
} catch ( any e ) {
return serializeJSON( arguments.value );
}
}

/**
* Decorate a mock object with all the necessary methods and properties
*
Expand Down
149 changes: 149 additions & 0 deletions tests/specs/mockbox/MockBoxSetRangeTest.bx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Tests MockBox's $args() argument matching against BoxLang's native Set and Range
* types (TESTBOX-448). This file is intentionally a .bx file, not a .cfc: BoxLang's
* `..` range operator and `set{}`/setOf()/setNew() constructs are BoxLang-only
* language/BIF surface that Lucee and Adobe don't have, and TestBox's own bundle
* discovery (getSpecPaths() in TestBox.cfc) already skips *.bx bundles entirely when
* not running on BoxLang - so this file is simply never compiled or run on those
* engines, avoiding both a parse-time failure (the `..` operator isn't valid CFML
* syntax at all) and a runtime one (isBoxSet()/isRange() don't exist there).
*/
class extends="testbox.system.BaseSpec" {

variables.mockBox = "";

function run(){
describe( "MockBox $args() with BoxLang Sets and Ranges", function(){
beforeEach( function(){
mockBox = getMockBox();
} );

it( "matches a Set argument regardless of its backing variant or insertion order", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args( data = setNew( type = "linked", values = [ 3, 1, 2 ] ) )
.$results( "matched" );

// Same elements, different variant and different insertion/iteration order
var actual = setNew( type = "sorted", values = [ 1, 2, 3 ] );

expect( service.save( data = actual ) ).toBe( "matched" );
} );

it( "does not match a Set with different elements", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args( data = setOf( 1, 2, 3 ) )
.$results( "should-not-match" );

var result = service.save( data = setOf( 1, 2, 4 ) );

expect( isNull( result ) || result != "should-not-match" ).toBeTrue();
} );

it( "does not match an Array holding the same elements as a Set", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args( data = setOf( 1, 2, 3 ) )
.$results( "should-not-match" );

// A Set and an Array are different types - a Set match must not leak
// into matching an Array with the same content
var result = service.save( data = [ 1, 2, 3 ] );

expect( isNull( result ) || result != "should-not-match" ).toBeTrue();
} );

it( "recursively normalizes and order-independently matches a Set of structs", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args(
data = setNew(
type = "linked",
values = [ { a : 1, b : 2 }, { a : 3, b : 4 } ]
)
)
.$results( "matched" );

// Same struct elements, different struct key order AND different set element order
var actual = setNew(
type = "linked",
values = [ { b : 4, a : 3 }, { b : 2, a : 1 } ]
);

expect( service.save( data = actual ) ).toBe( "matched" );
} );

it( "matches a Range argument built with identical bounds", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args( data = 1..5 )
.$results( "matched" );

expect( service.save( data = 1..5 ) ).toBe( "matched" );
} );

it( "does not match a Range with different exclusivity", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args( data = 1..5 )
.$results( "should-not-match" );

// 1>..<5 excludes both endpoints - a structurally different range
var result = service.save( data = 1>..<5 );

expect( isNull( result ) || result != "should-not-match" ).toBeTrue();
} );

it( "does not match a Range with a different step", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args( data = 1..10 )
.$results( "should-not-match" );

var result = service.save( data = ( 1..10 ).step( 3 ) );

expect( isNull( result ) || result != "should-not-match" ).toBeTrue();
} );

it( "does not confuse a Range with a plain string that looks like one", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args( data = 1..5 )
.$results( "should-not-match" );

var result = service.save( data = "1..5" );

expect( isNull( result ) || result != "should-not-match" ).toBeTrue();
} );

it( "normalizes an unbounded Range without hanging or throwing", function(){
var service = mockBox.createStub();

service
.$( "save" )
.$args( data = 1.. )
.$results( "matched" );

expect( service.save( data = 1.. ) ).toBe( "matched" );
} );
} );
}

}
125 changes: 125 additions & 0 deletions tests/specs/mockbox/MockBoxTest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,131 @@
$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 ) );
}

// A string value that happens to contain delimiter-like characters (comma, equals,
// brackets) must not be confused with a structurally different struct/array that
// happens to normalize to the same raw text if delimiters aren't escaped (TESTBOX-448)
function testMockArgsNoDelimiterCollision(){
var service = getMockBox().createStub();

service
.$( "save" )
.$args( data = { a : 1, b : 2 } )
.$results( "should-not-match" );

// A single key whose value contains a comma and equals sign that, if naively
// joined without escaping, would produce the exact same string as
// { a: 1, b: 2 } serialized as "a=1,b=2"
var result = service.save( data = { a : "1,b=2" } );

$assert.isTrue(
isNull( result ) || result != "should-not-match",
"A struct with a comma/equals-containing string value falsely matched a differently-shaped struct - delimiter collision in argument hashing"
);

// Same idea for arrays: an embedded comma must not make two different arrays hash the same
var service2 = getMockBox().createStub();
service2
.$( "save" )
.$args( items = [ "1,2", "3" ] )
.$results( "should-not-match-either" );

var result2 = service2.save( items = [ "1", "2,3" ] );
$assert.isTrue(
isNull( result2 ) || result2 != "should-not-match-either",
"An array with a comma-containing string element falsely matched a differently-shaped array"
);
}

function testGetProperty(){
mock = getMockBox().createStub();
mock.luis = "Majano";
Expand Down
Loading