2222use Config \Services ;
2323use PHPUnit \Framework \Attributes \Group ;
2424use stdClass ;
25+ use Tests \Support \API \ChildTransformer ;
26+ use Tests \Support \API \ParentTransformer ;
2527
2628/**
2729 * @internal
@@ -33,12 +35,14 @@ protected function setUp(): void
3335 {
3436 parent ::setUp ();
3537
38+ Services::resetSingle ('request ' );
3639 Services::superglobals ()->setGetArray ([]);
3740 }
3841
3942 protected function tearDown (): void
4043 {
4144 Services::superglobals ()->setGetArray ([]);
45+ Services::resetSingle ('request ' );
4246
4347 parent ::tearDown ();
4448 }
@@ -641,4 +645,160 @@ protected function includePosts(): array
641645 $ this ->assertArrayHasKey ('posts ' , $ result );
642646 $ this ->assertSame ([['id ' => 1 , 'title ' => 'Post 1 ' ]], $ result ['posts ' ]);
643647 }
648+
649+ public function testNestedTransformerDoesNotInheritIncludeState (): void
650+ {
651+ // The child transformer has no includeChildren() method. If the root
652+ // request's `include=children` leaked into it, transforming the child
653+ // would raise an ApiException for the missing include method.
654+ $ request = $ this ->createMockRequest ('include=children ' );
655+ Services::injectMock ('request ' , $ request );
656+
657+ $ transformer = new ParentTransformer ($ request );
658+
659+ $ result = $ transformer ->transform (['id ' => 1 ]);
660+
661+ $ this ->assertSame ([
662+ 'parent_id ' => 1 ,
663+ 'children ' => ['child_id ' => 99 , 'status ' => 'transformed ' ],
664+ ], $ result );
665+ }
666+
667+ public function testNestedTransformerDoesNotInheritFieldFilter (): void
668+ {
669+ // `fields=parent_id` applies to the root only. If it leaked into the
670+ // child, array_intersect_key would strip every child field, leaving [].
671+ $ request = $ this ->createMockRequest ('include=children&fields=parent_id ' );
672+ Services::injectMock ('request ' , $ request );
673+
674+ $ transformer = new ParentTransformer ($ request );
675+
676+ $ result = $ transformer ->transform (['id ' => 1 ]);
677+
678+ $ this ->assertSame ([
679+ 'parent_id ' => 1 ,
680+ 'children ' => ['child_id ' => 99 , 'status ' => 'transformed ' ],
681+ ], $ result );
682+ }
683+
684+ public function testNestedCollectionTransformerDoesNotInheritState (): void
685+ {
686+ $ request = $ this ->createMockRequest ('include=childrenCollection&fields=parent_id ' );
687+ Services::injectMock ('request ' , $ request );
688+
689+ $ transformer = new ParentTransformer ($ request );
690+
691+ $ result = $ transformer ->transform (['id ' => 1 ]);
692+
693+ $ this ->assertSame ([
694+ 'parent_id ' => 1 ,
695+ 'childrenCollection ' => [
696+ ['child_id ' => 77 , 'status ' => 'transformed ' ],
697+ ['child_id ' => 88 , 'status ' => 'transformed ' ],
698+ ],
699+ ], $ result );
700+ }
701+
702+ public function testBareNestedInstantiationDoesNotInheritState (): void
703+ {
704+ // Reproduces the exact leak vector: a child created with a bare
705+ // `new ChildTransformer()` (no request passed) inside an include
706+ // method must not pick up the root request's scope from request().
707+ $ request = $ this ->createMockRequest ('include=children&fields=parent_id ' );
708+ Services::injectMock ('request ' , $ request );
709+
710+ $ root = new ParentTransformer ();
711+
712+ $ result = $ root ->transform (['id ' => 5 ]);
713+
714+ $ this ->assertSame ([
715+ 'parent_id ' => 5 ,
716+ 'children ' => ['child_id ' => 99 , 'status ' => 'transformed ' ],
717+ ], $ result );
718+ }
719+
720+ public function testNestedTransformerHonorsExplicitRequest (): void
721+ {
722+ // A child created with an explicitly passed request must honor that
723+ // request's scope even while nested - the isolation only suppresses
724+ // the implicit global fallback, not deliberate developer intent.
725+ $ request = $ this ->createMockRequest ('include=explicitChild&fields=child_id,parent_id ' );
726+ Services::injectMock ('request ' , $ request );
727+
728+ $ transformer = new ParentTransformer ($ request );
729+
730+ $ result = $ transformer ->transform (['id ' => 1 ]);
731+
732+ $ this ->assertSame ([
733+ 'parent_id ' => 1 ,
734+ 'explicitChild ' => ['child_id ' => 99 ],
735+ ], $ result );
736+ }
737+
738+ public function testRootScopeStillAppliesAfterNesting (): void
739+ {
740+ // Sanity check that the root transformer keeps applying its own scope
741+ // while nested children are isolated.
742+ $ request = $ this ->createMockRequest ('include=children&fields=parent_id ' );
743+ Services::injectMock ('request ' , $ request );
744+
745+ $ transformer = new ParentTransformer ($ request );
746+
747+ $ result = $ transformer ->transform (['id ' => 1 , 'secret ' => 'hidden ' ]);
748+
749+ // Root keeps only parent_id (plus the include key), the child is intact.
750+ $ this ->assertArrayHasKey ('parent_id ' , $ result );
751+ $ this ->assertArrayNotHasKey ('secret ' , $ result );
752+ $ this ->assertSame (['child_id ' => 99 , 'status ' => 'transformed ' ], $ result ['children ' ]);
753+ }
754+
755+ public function testDepthIsRestoredAfterIncludeThrows (): void
756+ {
757+ $ request = $ this ->createMockRequest ('include=nonexistent ' );
758+ Services::injectMock ('request ' , $ request );
759+
760+ $ throwingRoot = new class ($ request ) extends BaseTransformer {
761+ public function toArray (mixed $ resource ): array
762+ {
763+ return $ resource ;
764+ }
765+ };
766+
767+ try {
768+ $ throwingRoot ->transform (['id ' => 1 , 'name ' => 'Test ' ]);
769+ $ this ->fail ('Expected ApiException was not thrown. ' );
770+ } catch (ApiException ) {
771+ // expected
772+ }
773+
774+ // The nesting depth must be balanced after the exception, so a fresh
775+ // root transformer still applies the request scope (depth back to 0).
776+ $ fieldsRequest = $ this ->createMockRequest ('fields=id ' );
777+ Services::injectMock ('request ' , $ fieldsRequest );
778+
779+ $ nextRoot = new class ($ fieldsRequest ) extends BaseTransformer {
780+ public function toArray (mixed $ resource ): array
781+ {
782+ return $ resource ;
783+ }
784+ };
785+
786+ $ result = $ nextRoot ->transform (['id ' => 1 , 'name ' => 'Test ' ]);
787+
788+ $ this ->assertSame (['id ' => 1 ], $ result );
789+ }
790+
791+ public function testBareNestedTransformerStillUsedByChildTransformerDirectly (): void
792+ {
793+ // When ChildTransformer is itself the root (no parent), it must apply
794+ // request scope as usual - the isolation only affects nesting.
795+ $ request = $ this ->createMockRequest ('fields=child_id ' );
796+ Services::injectMock ('request ' , $ request );
797+
798+ $ transformer = new ChildTransformer ($ request );
799+
800+ $ result = $ transformer ->transform (['id ' => 42 ]);
801+
802+ $ this ->assertSame (['child_id ' => 42 ], $ result );
803+ }
644804}
0 commit comments