1+ <?php
2+
3+ namespace Tests \OpenTelemetry \Formatters ;
4+
5+ /**
6+ * Copyright 2026 OpenStack Foundation
7+ * Licensed under the Apache License, Version 2.0 (the "License");
8+ * you may not use this file except in compliance with the License.
9+ * You may obtain a copy of the License at
10+ * http://www.apache.org/licenses/LICENSE-2.0
11+ * Unless required by applicable law or agreed to in writing, software
12+ * distributed under the License is distributed on an "AS IS" BASIS,
13+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+ * See the License for the specific language governing permissions and
15+ * limitations under the License.
16+ **/
17+
18+ use App \Audit \AbstractAuditLogFormatter ;
19+ use App \Audit \AuditLogFormatterFactory ;
20+ use App \Audit \AuditContext ;
21+ use App \Audit \Interfaces \IAuditStrategy ;
22+ use App \Audit \IAuditLogFormatter ;
23+ use Illuminate \Support \Facades \App ;
24+ use RecursiveDirectoryIterator ;
25+ use RecursiveIteratorIterator ;
26+ use Tests \OpenTelemetry \Formatters \Support \FormatterTestHelper ;
27+ use Tests \OpenTelemetry \Formatters \Support \AuditContextBuilder ;
28+ use PHPUnit \Framework \TestCase ;
29+
30+ class AllFormattersIntegrationTest extends TestCase
31+ {
32+ private AuditContext $ defaultContext ;
33+ private const BASE_FORMATTERS_NAMESPACE = 'App \\Audit \\ConcreteFormatters \\' ;
34+ private string $ BASE_FORMATTERS_DIR ;
35+ private string $ AbstractAuditLogFormatterClass ;
36+ private const CHILD_ENTITY_DIR_NAME = 'ChildEntityFormatters ' ;
37+
38+ public function __construct ()
39+ {
40+ $ this ->BASE_FORMATTERS_DIR = App::path ('Audit ' . DIRECTORY_SEPARATOR . 'ConcreteFormatters ' );
41+ $ this ->AbstractAuditLogFormatterClass = AbstractAuditLogFormatter::class;
42+ }
43+
44+ protected function setUp (): void
45+ {
46+ parent ::setUp ();
47+ $ this ->defaultContext = AuditContextBuilder::default ()->build ();
48+ }
49+
50+ private function discoverFormatters (?string $ directory = null ): array
51+ {
52+ $ directory = $ directory ?? $ this ->BASE_FORMATTERS_DIR ;
53+ $ formatters = [];
54+
55+ if (!is_dir ($ directory )) {
56+ return $ formatters ;
57+ }
58+
59+ $ iterator = new RecursiveIteratorIterator (
60+ new RecursiveDirectoryIterator ($ directory , RecursiveDirectoryIterator::SKIP_DOTS )
61+ );
62+
63+ foreach ($ iterator as $ file ) {
64+ if (
65+ $ file ->getExtension () !== 'php ' or
66+ ($ file ->isDir () and $ file ->getBasename () === self ::CHILD_ENTITY_DIR_NAME )
67+ ) {
68+ continue ;
69+ }
70+
71+ $ className = $ this ->buildClassName ($ file ->getPathname (), $ directory );
72+
73+ if (class_exists ($ className ) && $ this ->isMainFormatter ($ className )) {
74+ $ formatters [] = $ className ;
75+ }
76+ }
77+
78+ return array_values ($ formatters );
79+ }
80+
81+
82+ private function buildClassName (string $ filePath , string $ basePath ): string
83+ {
84+ $ relativePath = str_replace ([$ basePath . DIRECTORY_SEPARATOR , '.php ' ], '' , $ filePath );
85+ $ classPath = str_replace (DIRECTORY_SEPARATOR , '\\' , $ relativePath );
86+ return self ::BASE_FORMATTERS_NAMESPACE . $ classPath ;
87+ }
88+
89+ private function isMainFormatter (string $ className ): bool
90+ {
91+ try {
92+ $ reflection = new \ReflectionClass ($ className );
93+
94+ if ($ reflection ->isAbstract () || $ reflection ->isInterface ()) {
95+ return false ;
96+ }
97+
98+ $ genericFormatters = [
99+ 'EntityCreationAuditLogFormatter ' ,
100+ 'EntityDeletionAuditLogFormatter ' ,
101+ 'EntityUpdateAuditLogFormatter ' ,
102+ 'EntityCollectionUpdateAuditLogFormatter ' ,
103+ ];
104+
105+ return !in_array ($ reflection ->getShortName (), $ genericFormatters ) &&
106+ $ reflection ->isSubclassOf (AbstractAuditLogFormatter::class);
107+ } catch (\ReflectionException $ e ) {
108+ return false ;
109+ }
110+ }
111+
112+ public function testAllFormattersCanBeInstantiated (): void
113+ {
114+ foreach ($ this ->discoverFormatters () as $ formatterClass ) {
115+ try {
116+ $ formatter = FormatterTestHelper::assertFormatterCanBeInstantiated (
117+ $ formatterClass ,
118+ IAuditStrategy::EVENT_ENTITY_CREATION
119+ );
120+
121+ FormatterTestHelper::assertFormatterHasSetContextMethod ($ formatter );
122+ $ formatter ->setContext ($ this ->defaultContext );
123+ $ this ->assertNotNull ($ formatter );
124+ } catch (\Exception $ e ) {
125+ $ this ->fail ("Failed to validate {$ formatterClass }: " . $ e ->getMessage ());
126+ }
127+ }
128+ }
129+
130+ public function testAllFormatterConstructorParametersRequired (): void
131+ {
132+ $ errors = [];
133+ $ count = 0 ;
134+
135+ foreach ($ this ->discoverFormatters () as $ formatterClass ) {
136+ try {
137+ FormatterTestHelper::assertFormatterHasValidConstructor ($ formatterClass );
138+ $ count ++;
139+ } catch (\Exception $ e ) {
140+ $ errors [] = "{$ formatterClass }: " . $ e ->getMessage ();
141+ }
142+ }
143+
144+ $ this ->assertEmpty ($ errors , implode ("\n" , $ errors ));
145+ $ this ->assertGreaterThan (0 , $ count , 'At least one formatter should be validated ' );
146+ }
147+
148+ public function testAllFormattersHandleAllEventTypes (): void
149+ {
150+ $ eventTypes = [
151+ IAuditStrategy::EVENT_ENTITY_CREATION ,
152+ IAuditStrategy::EVENT_ENTITY_UPDATE ,
153+ IAuditStrategy::EVENT_ENTITY_DELETION ,
154+ IAuditStrategy::EVENT_COLLECTION_UPDATE ,
155+ ];
156+
157+ $ errors = [];
158+ $ unsupported = [];
159+
160+ foreach ($ this ->discoverFormatters () as $ formatterClass ) {
161+ foreach ($ eventTypes as $ eventType ) {
162+ try {
163+ $ formatter = FormatterTestHelper::assertFormatterCanBeInstantiated (
164+ $ formatterClass ,
165+ $ eventType
166+ );
167+ $ formatter ->setContext ($ this ->defaultContext );
168+ $ this ->assertNotNull ($ formatter );
169+ } catch (\Exception $ e ) {
170+ if (strpos ($ e ->getMessage (), 'event type ' ) !== false ) {
171+ $ unsupported [] = "{$ formatterClass } does not support {$ eventType }" ;
172+ } else {
173+ $ errors [] = "{$ formatterClass } with {$ eventType }: " . $ e ->getMessage ();
174+ }
175+ }
176+ }
177+ }
178+
179+ $ this ->assertEmpty ($ errors , "Event type handling failed: \n" . implode ("\n" , $ errors ));
180+ }
181+
182+ public function testAllFormattersHandleInvalidSubjectGracefully (): void
183+ {
184+ $ errors = [];
185+ $ count = 0 ;
186+
187+ foreach ($ this ->discoverFormatters () as $ formatterClass ) {
188+ try {
189+ $ formatter = FormatterTestHelper::assertFormatterCanBeInstantiated (
190+ $ formatterClass ,
191+ IAuditStrategy::EVENT_ENTITY_CREATION
192+ );
193+ $ formatter ->setContext ($ this ->defaultContext );
194+
195+ FormatterTestHelper::assertFormatterHandlesInvalidSubjectGracefully ($ formatter , new \stdClass ());
196+ $ count ++;
197+ } catch (\Exception $ e ) {
198+ $ errors [] = "{$ formatterClass }: " . $ e ->getMessage ();
199+ }
200+ }
201+
202+ $ this ->assertEmpty ($ errors , implode ("\n" , $ errors ));
203+ $ this ->assertGreaterThan (0 , $ count , 'At least one formatter should be validated ' );
204+ }
205+
206+ public function testAllFormattersHandleMissingContextGracefully (): void
207+ {
208+ $ errors = [];
209+ $ count = 0 ;
210+
211+ foreach ($ this ->discoverFormatters () as $ formatterClass ) {
212+ try {
213+ $ formatter = FormatterTestHelper::assertFormatterCanBeInstantiated (
214+ $ formatterClass ,
215+ IAuditStrategy::EVENT_ENTITY_CREATION
216+ );
217+
218+ $ result = $ formatter ->format (new \stdClass (), []);
219+
220+ $ this ->assertNull (
221+ $ result ,
222+ "{$ formatterClass }::format() must return null when context not set, got " .
223+ (is_string ($ result ) ? "' {$ result }' " : gettype ($ result ))
224+ );
225+ $ count ++;
226+ } catch (\Exception $ e ) {
227+ $ errors [] = "{$ formatterClass } threw exception without context: " . $ e ->getMessage ();
228+ }
229+ }
230+
231+ $ this ->assertEmpty ($ errors , implode ("\n" , $ errors ));
232+ $ this ->assertGreaterThan (0 , $ count , 'At least one formatter should be validated ' );
233+ }
234+
235+ public function testFormattersHandleEmptyChangeSetGracefully (): void
236+ {
237+ $ errors = [];
238+ $ count = 0 ;
239+
240+ foreach ($ this ->discoverFormatters () as $ formatterClass ) {
241+ try {
242+ $ formatter = FormatterTestHelper::assertFormatterCanBeInstantiated (
243+ $ formatterClass ,
244+ IAuditStrategy::EVENT_ENTITY_UPDATE
245+ );
246+ $ formatter ->setContext ($ this ->defaultContext );
247+
248+ FormatterTestHelper::assertFormatterHandlesEmptyChangesetGracefully ($ formatter );
249+ $ count ++;
250+ } catch (\Exception $ e ) {
251+ $ errors [] = "{$ formatterClass }: " . $ e ->getMessage ();
252+ }
253+ }
254+
255+ $ this ->assertEmpty ($ errors , implode ("\n" , $ errors ));
256+ $ this ->assertGreaterThan (0 , $ count , 'At least one formatter should be validated ' );
257+ }
258+
259+ public function testAllFormattersImplementCorrectInterfaces (): void
260+ {
261+ $ errors = [];
262+ $ count = 0 ;
263+
264+ foreach ($ this ->discoverFormatters () as $ formatterClass ) {
265+ try {
266+ FormatterTestHelper::assertFormatterExtendsAbstractFormatter ($ formatterClass );
267+ FormatterTestHelper::assertFormatterHasValidFormatMethod ($ formatterClass );
268+ $ count ++;
269+ } catch (\Exception $ e ) {
270+ $ errors [] = "{$ formatterClass }: " . $ e ->getMessage ();
271+ }
272+ }
273+
274+ $ this ->assertEmpty ($ errors , implode ("\n" , $ errors ));
275+ $ this ->assertGreaterThan (0 , $ count , 'At least one formatter should be validated ' );
276+ }
277+
278+ public function testAllFormattersHaveCorrectFormatMethodSignature (): void
279+ {
280+ $ errors = [];
281+ $ count = 0 ;
282+
283+ foreach ($ this ->discoverFormatters () as $ formatterClass ) {
284+ try {
285+ FormatterTestHelper::assertFormatterHasValidFormatMethod ($ formatterClass );
286+ $ count ++;
287+ } catch (\Exception $ e ) {
288+ $ errors [] = "{$ formatterClass }: " . $ e ->getMessage ();
289+ }
290+ }
291+
292+ $ this ->assertEmpty ($ errors , implode ("\n" , $ errors ));
293+ $ this ->assertGreaterThan (0 , $ count , 'At least one formatter should be validated ' );
294+ }
295+
296+
297+ public function testAuditContextHasRequiredFields (): void
298+ {
299+ $ context = $ this ->defaultContext ;
300+
301+ $ this ->assertIsInt ($ context ->userId );
302+ $ this ->assertGreaterThan (0 , $ context ->userId );
303+
304+ $ this ->assertIsString ($ context ->userEmail );
305+ $ this ->assertNotEmpty ($ context ->userEmail );
306+ $ this ->assertNotFalse (
307+ filter_var ($ context ->userEmail , FILTER_VALIDATE_EMAIL ),
308+ "User email ' {$ context ->userEmail }' is not valid "
309+ );
310+
311+ $ this ->assertIsString ($ context ->userFirstName );
312+ $ this ->assertNotEmpty ($ context ->userFirstName );
313+
314+ $ this ->assertIsString ($ context ->userLastName );
315+ $ this ->assertNotEmpty ($ context ->userLastName );
316+
317+ $ this ->assertIsString ($ context ->uiApp );
318+ $ this ->assertNotEmpty ($ context ->uiApp );
319+
320+ $ this ->assertIsString ($ context ->uiFlow );
321+ $ this ->assertNotEmpty ($ context ->uiFlow );
322+
323+ $ this ->assertIsString ($ context ->route );
324+ $ this ->assertNotEmpty ($ context ->route );
325+
326+ $ this ->assertIsString ($ context ->httpMethod );
327+ $ this ->assertNotEmpty ($ context ->httpMethod );
328+
329+ $ this ->assertIsString ($ context ->clientIp );
330+ $ this ->assertNotEmpty ($ context ->clientIp );
331+ $ this ->assertNotFalse (
332+ filter_var ($ context ->clientIp , FILTER_VALIDATE_IP ),
333+ "Client IP ' {$ context ->clientIp }' is not valid "
334+ );
335+
336+ $ this ->assertIsString ($ context ->userAgent );
337+ $ this ->assertNotEmpty ($ context ->userAgent );
338+ }
339+
340+ public function testAuditStrategyDefinesAllEventTypes (): void
341+ {
342+ $ this ->assertTrue (defined ('App\Audit\Interfaces\IAuditStrategy::EVENT_ENTITY_CREATION ' ));
343+ $ this ->assertTrue (defined ('App\Audit\Interfaces\IAuditStrategy::EVENT_ENTITY_UPDATE ' ));
344+ $ this ->assertTrue (defined ('App\Audit\Interfaces\IAuditStrategy::EVENT_ENTITY_DELETION ' ));
345+ $ this ->assertTrue (defined ('App\Audit\Interfaces\IAuditStrategy::EVENT_COLLECTION_UPDATE ' ));
346+ }
347+
348+ public function testFactoryInstantiatesCorrectFormatterForSubject (): void
349+ {
350+ $ factory = new AuditLogFormatterFactory ();
351+
352+ $ unknownSubject = new \stdClass ();
353+ $ formatter = $ factory ->make ($ this ->defaultContext , $ unknownSubject , IAuditStrategy::EVENT_ENTITY_CREATION );
354+
355+ $ this ->assertTrue (
356+ $ formatter === null || $ formatter instanceof IAuditLogFormatter,
357+ 'Factory must return null or IAuditLogFormatter for unknown subject type '
358+ );
359+
360+ $ validSubject = new class {
361+ public function __toString ()
362+ {
363+ return 'MockEntity ' ;
364+ }
365+ };
366+
367+ $ formatter = $ factory ->make ($ this ->defaultContext , $ validSubject , IAuditStrategy::EVENT_ENTITY_CREATION );
368+
369+ if ($ formatter !== null ) {
370+ $ this ->assertInstanceOf (
371+ IAuditLogFormatter::class,
372+ $ formatter ,
373+ 'Factory must return IAuditLogFormatter instance for valid subject '
374+ );
375+
376+ $ this ->assertNotNull ($ formatter , 'Returned formatter must not be null ' );
377+ }
378+ }
379+ }
0 commit comments