diff --git a/docs/architecture/ADMIN_QUERY_DELIVERY_OPERATIONS_BLUEPRINT.md b/docs/architecture/ADMIN_QUERY_DELIVERY_OPERATIONS_BLUEPRINT.md index 07d557a..36af7e8 100644 --- a/docs/architecture/ADMIN_QUERY_DELIVERY_OPERATIONS_BLUEPRINT.md +++ b/docs/architecture/ADMIN_QUERY_DELIVERY_OPERATIONS_BLUEPRINT.md @@ -392,7 +392,7 @@ final class DeliveryOperationsAdminQueryRequestDTO implements \JsonSerializable - **Retry Bounds**: `attemptNoMin` and `attemptNoMax` must be unsigned (>= 0). If both are set, `attemptNoMin <= attemptNoMax`. - **Text Search**: `errorMessageLike` uses safe exact contains search with escaping (`LIKE :error_message_like ESCAPE '\\'`). Escaping must natively escape `\`, `%`, and `_`. Case insensitivity depends on the table's collation. - **Null-State Tri-State**: `nullStateFilters` accepts an associative array mapping exactly to these allowed property names: `actorType`, `actorId`, `targetType`, `targetId`, `scheduledAt`, `completedAt`, `correlationId`, `requestId`, `provider`, `providerMessageId`, `errorCode`, `errorMessage`. Values must be strictly `bool` (`true` -> `IS NULL`, `false` -> `IS NOT NULL`). An exception is thrown for invalid properties or non-bool values. Duplicate semantic conditions (e.g., both equality and null-state) naturally translate into SQL `WHERE column = X AND column IS NULL`, producing 0 rows as intended. -- **Metadata JSON Search**: `metadataFilters` must be an exact associative array mapping `['stringPath' => scalarValue]`. No arbitrary JSON path evaluation. Maximum 5 filters per request. The path format must strictly enforce leading `$.`, exactly 1 to 5 non-empty ASCII `[A-Za-z0-9_]+` segments, dot separators only, no repeated dots, and maximum total length 64 bytes/characters. Allowed scalar value types: `string|int|float|bool|null`. Values must be successfully encoded via `json_encode(..., JSON_THROW_ON_ERROR)`. A JSON `null` filter explicitly searches for `CAST(NULL AS JSON)` value stored in the document, whereas missing a path returns NULL which requires `JSON_CONTAINS_PATH(metadata, 'one', :path) = 1` checks to differentiate. Null vs missing must be handled correctly in SQL. +- **Metadata JSON Search**: `metadataFilters` must be an exact associative array mapping `['stringPath' => scalarValue]`. No arbitrary JSON path evaluation. Maximum 5 filters per request. The path format must strictly enforce leading `$.`, exactly 1 to 5 non-empty ASCII `[A-Za-z0-9_]+` segments, dot separators only, no repeated dots, and maximum total length 64 bytes/characters. Allowed scalar value types: `string|int|float|bool|null`. Values must be successfully encoded via `json_encode(..., JSON_THROW_ON_ERROR)`. A JSON `null` filter explicitly searches for `'null' JSON literal` value stored in the document, whereas missing a path returns NULL which requires `JSON_CONTAINS_PATH(metadata, 'one', :path) = 1` checks to differentiate. Null vs missing must be handled correctly in SQL. ### 3.5 Result DTO `DeliveryOperationsAdminPageResultDTO` @@ -500,7 +500,7 @@ For `errorMessageLike`, the parameter value must be escaped using: This matches the explicit `ESCAPE '\\'` SQL clause. ### Metadata Paths -Path format strictly matches `/^\$\.[A-Za-z0-9_]+(\.[A-Za-z0-9_]+){0,4}$/`. Callers must supply the leading `$.`. Maximum 5 keys. Maximum path length 64 bytes/characters. Deterministic placeholders (e.g., `meta_path_0`, `meta_val_0`). Parameter values encoded via `json_encode(..., JSON_THROW_ON_ERROR)`. JSON-null searches natively match `CAST('null' AS JSON)` if the path exists, enforced via `JSON_CONTAINS_PATH`. +Path format strictly matches `/^\$\.[A-Za-z0-9_]+(\.[A-Za-z0-9_]+){0,4}$/`. Callers must supply the leading `$.`. Maximum 5 keys. Maximum path length 64 bytes/characters. Deterministic placeholders (e.g., `meta_path_exists_N`, `meta_value_N`). Parameter values encoded via `json_encode(..., JSON_THROW_ON_ERROR)`. JSON-null searches natively match `the 'null' JSON literal` if the path exists, enforced via `JSON_CONTAINS_PATH`. ### SQL Semantics - **Empty filters:** generates empty `whereSql` (no `WHERE` string added to data/count SQL). diff --git a/docs/roadmap/ADMIN_QUERY_API_ROADMAP.md b/docs/roadmap/ADMIN_QUERY_API_ROADMAP.md index 60f152c..9a5b51d 100644 --- a/docs/roadmap/ADMIN_QUERY_API_ROADMAP.md +++ b/docs/roadmap/ADMIN_QUERY_API_ROADMAP.md @@ -1,6 +1,6 @@ # Admin Query API Roadmap -**Status:** Phase 4 Complete / DeliveryOperations Runtime Implemented / Pending Release +**Status:** Phase 4 Active / DeliveryOperations Runtime Implemented / Pending Release ## 1. Scope Boundary @@ -93,9 +93,9 @@ Implement the Admin Query API for domains that never received the incorrect post These are new post-v1.0 features, not corrections to the first-release Runtime. -- **Status:** Phase 4 is active. DiagnosticsTelemetry Runtime is complete and merged. +- **Status:** Phase 4 is active. DiagnosticsTelemetry Runtime is complete and merged. DeliveryOperations Runtime Implemented. - `DiagnosticsTelemetry`: [Owner Approved / Runtime Implemented / Complete](../architecture/ADMIN_QUERY_DIAGNOSTICS_TELEMETRY_BLUEPRINT.md) - - `DeliveryOperations`: Owner Approved / Runtime Implemented / Complete + - `DeliveryOperations`: Owner Approved / Runtime Implemented / Pending Merge ### Phase 5 — Reporting and Dashboard Summary Contracts @@ -140,7 +140,7 @@ Every implementation phase must prove all of the following: ## 6. Current Gate -- Phase 4 is complete; -- DeliveryOperations Runtime is complete; +- Phase 4 is active; +- DeliveryOperations Runtime is complete / Pending Merge; - reporting/dashboard does not start until Admin pagination is released and verified for all six domains; - no release/tag authorized. diff --git a/src/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsRowMapper.php b/src/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsRowMapper.php index 8d5d42a..0b47971 100644 --- a/src/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsRowMapper.php +++ b/src/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsRowMapper.php @@ -62,9 +62,18 @@ public function map(array $row): DeliveryOperationsViewDTO if (isset($row['metadata']) && is_string($row['metadata']) && $row['metadata'] !== '') { try { $decoded = json_decode($row['metadata'], true, 512, JSON_THROW_ON_ERROR); - if (is_array($decoded) && array_is_list($decoded) === false || $decoded === []) { - /** @var array $decoded */ - $metadata = $decoded; + if (is_array($decoded)) { + $allKeysStrings = true; + foreach (array_keys($decoded) as $decodedKey) { + if (!is_string($decodedKey)) { + $allKeysStrings = false; + break; + } + } + if ($allKeysStrings) { + /** @var array $decoded */ + $metadata = $decoded; + } } } catch (JsonException) { // Mapping failure or corruption -> fallback to null. diff --git a/tests/Integration/DeliveryOperations/DeliveryOperationsAdminQueryMysqlRepositoryTest.php b/tests/Integration/DeliveryOperations/DeliveryOperationsAdminQueryMysqlRepositoryTest.php index a1654ab..a725937 100644 --- a/tests/Integration/DeliveryOperations/DeliveryOperationsAdminQueryMysqlRepositoryTest.php +++ b/tests/Integration/DeliveryOperations/DeliveryOperationsAdminQueryMysqlRepositoryTest.php @@ -308,18 +308,43 @@ public function testItPreservesCallerOwnedTransaction(): void $this->assertSame(0, $res2->filtered); } - public function testItTranslatesRealPdoExceptionToStorageException(): void + public function testItTranslatesRealMappingFailureToStorageException(): void { - $this->pdo->exec('DROP TABLE maa_event_logging_delivery_operations'); + $this->insertLog(eventId: 'e1'); + + $this->pdo->exec("RENAME TABLE maa_event_logging_delivery_operations TO maa_event_logging_delivery_operations_real"); + $this->pdo->exec("CREATE VIEW maa_event_logging_delivery_operations AS SELECT id, event_id, channel, operation_type, actor_type, actor_id, target_type, target_id, status, attempt_no, scheduled_at, completed_at, correlation_id, request_id, provider, provider_message_id, error_code, error_message, metadata, 'invalid-date' as occurred_at FROM maa_event_logging_delivery_operations_real"); + + try { + $this->repository->paginate(new DeliveryOperationsAdminQueryRequestDTO()); + $this->fail('Expected DeliveryOperationsStorageException'); + } catch (DeliveryOperationsStorageException $e) { + $this->assertStringContainsString('Failed to map DeliveryOperations row', $e->getMessage()); + } finally { + $this->pdo->exec("DROP VIEW IF EXISTS maa_event_logging_delivery_operations"); + $this->pdo->exec("RENAME TABLE maa_event_logging_delivery_operations_real TO maa_event_logging_delivery_operations"); + } + } + + public function testItTranslatesRealPdoExceptionToStorageExceptionAndPreservesTransaction(): void + { + $this->pdo->beginTransaction(); + + $this->pdo->exec('SET @@session.max_join_size=1'); + + $this->insertLog(eventId: 'e1'); + $this->insertLog(eventId: 'e2'); try { $this->repository->paginate(new DeliveryOperationsAdminQueryRequestDTO()); $this->fail('Expected DeliveryOperationsStorageException'); } catch (DeliveryOperationsStorageException $e) { - $this->assertStringContainsString('Failed to query DeliveryOperations records:', $e->getMessage()); - $this->assertInstanceOf(\PDOException::class, $e->getPrevious()); + $this->assertStringContainsString('Failed to query DeliveryOperations records', $e->getMessage()); } finally { - $this->setUp(); // Re-create schema for subsequent tests if necessary + $this->pdo->exec('SET @@session.max_join_size=DEFAULT'); } + + $this->assertTrue($this->pdo->inTransaction()); + $this->pdo->rollBack(); } } diff --git a/tests/Regression/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsQueryMysqlRepositoryRegressionTest.php b/tests/Regression/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsQueryMysqlRepositoryRegressionTest.php index 348dcc3..4b6f028 100644 --- a/tests/Regression/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsQueryMysqlRepositoryRegressionTest.php +++ b/tests/Regression/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsQueryMysqlRepositoryRegressionTest.php @@ -239,15 +239,112 @@ public function testDtoPreservesPrimitiveSignatureAndDefaults(): void // Assert all but limit are nullable foreach ($params as $param) { + $type = $param->getType(); + $this->assertInstanceOf(\ReflectionNamedType::class, $type); if ($param->getName() === 'limit') { - $this->assertFalse($param->getType()->allowsNull()); + $this->assertFalse($type->allowsNull()); + $this->assertSame('int', $type->getName()); $this->assertTrue($param->isDefaultValueAvailable()); $this->assertSame(50, $param->getDefaultValue()); } else { - $this->assertTrue($param->getType()->allowsNull()); + $this->assertTrue($type->allowsNull()); $this->assertTrue($param->isDefaultValueAvailable()); $this->assertNull($param->getDefaultValue()); + + if (str_ends_with($param->getName(), 'At') || in_array($param->getName(), ['after', 'before'])) { + $this->assertSame(DateTimeImmutable::class, $type->getName()); + } elseif (in_array($param->getName(), ['actorId', 'targetId', 'cursorId'])) { + $this->assertSame('int', $type->getName()); + } else { + $this->assertSame('string', $type->getName()); + } } } } + + public function testPrimitiveRepositoryNeverCallsTransactionMethods(): void + { + $this->pdo->expects($this->never())->method('beginTransaction'); + $this->pdo->expects($this->never())->method('commit'); + $this->pdo->expects($this->never())->method('rollBack'); + + $this->pdo->expects($this->once()) + ->method('prepare') + ->willReturn($this->statement); + + $this->statement->expects($this->once()) + ->method('execute'); + + $this->statement->expects($this->once()) + ->method('fetchAll') + ->willReturn([]); + + $this->repository->find(new DeliveryOperationsQueryDTO()); + } + + public function testPrimitiveRepositoryNeverCallsTransactionMethodsOnFailure(): void + { + $this->pdo->expects($this->never())->method('beginTransaction'); + $this->pdo->expects($this->never())->method('commit'); + $this->pdo->expects($this->never())->method('rollBack'); + + $this->pdo->expects($this->once()) + ->method('prepare') + ->willThrowException(new PDOException('PDO failure')); + + $this->expectException(DeliveryOperationsStorageException::class); + $this->repository->find(new DeliveryOperationsQueryDTO()); + } + + public function testPrimitiveRepositoryContractBoundariesArePreserved(): void + { + $interfaces = class_implements(DeliveryOperationsQueryMysqlRepository::class); + $this->assertContains(\Maatify\EventLogging\DeliveryOperations\Contract\DeliveryOperationsQueryInterface::class, $interfaces); + $this->assertTrue((new \ReflectionClass(DeliveryOperationsQueryMysqlRepository::class))->isFinal()); + + $constructor = (new \ReflectionClass(DeliveryOperationsQueryMysqlRepository::class))->getConstructor(); + $this->assertNotNull($constructor); + $this->assertCount(1, $constructor->getParameters()); + $param = $constructor->getParameters()[0]; + $this->assertSame('pdo', $param->getName()); + $type = $param->getType(); + $this->assertInstanceOf(\ReflectionNamedType::class, $type); + $this->assertSame(PDO::class, $type->getName()); + + $findMethod = (new \ReflectionClass(DeliveryOperationsQueryMysqlRepository::class))->getMethod('find'); + $this->assertTrue($findMethod->isPublic()); + $this->assertCount(1, $findMethod->getParameters()); + $queryParam = $findMethod->getParameters()[0]; + $this->assertSame('query', $queryParam->getName()); + $queryType = $queryParam->getType(); + $this->assertInstanceOf(\ReflectionNamedType::class, $queryType); + $this->assertSame(DeliveryOperationsQueryDTO::class, $queryType->getName()); + + $returnType = $findMethod->getReturnType(); + $this->assertInstanceOf(\ReflectionNamedType::class, $returnType); + $this->assertSame('array', $returnType->getName()); + } + + public function testPrimitiveRepositoryBoundariesAndSchemaArePreservedUnchanged(): void + { + // Prove primitive read API is strictly separated from writer boundaries + $this->assertTrue(interface_exists(\Maatify\EventLogging\DeliveryOperations\Contract\DeliveryOperationsLoggerInterface::class)); + $this->assertTrue(class_exists(\Maatify\EventLogging\DeliveryOperations\Infrastructure\Mysql\DeliveryOperationsLoggerMysqlRepository::class)); + $this->assertTrue(class_exists(\Maatify\EventLogging\DeliveryOperations\Recorder\DeliveryOperationsRecorder::class)); + $this->assertTrue(class_exists(\Maatify\EventLogging\DeliveryOperations\Recorder\DeliveryOperationsDefaultPolicy::class)); + + // Schema verification + $schemaPath = __DIR__ . '/../../../../../src/DeliveryOperations/Database/schema.maa_event_logging_delivery_operations.sql'; + $this->assertFileExists($schemaPath); + $schema = file_get_contents($schemaPath); + $this->assertIsString($schema); + $this->assertStringContainsString('maa_event_logging_delivery_operations', $schema); + $this->assertStringContainsString('INDEX idx_delivery_ops_actor_time (actor_type, actor_id, occurred_at)', $schema); + + // Fail-open boundary verification (Recorder does not throw StorageException) + $recorderClass = new \ReflectionClass(\Maatify\EventLogging\DeliveryOperations\Recorder\DeliveryOperationsRecorder::class); + $docComment = $recorderClass->getMethod('record')->getDocComment(); + $docString = is_string($docComment) ? $docComment : ''; + $this->assertFalse(str_contains($docString, '@throws ' . DeliveryOperationsStorageException::class)); + } } \ No newline at end of file diff --git a/tests/Unit/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsAdminQueryMysqlRepositoryTest.php b/tests/Unit/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsAdminQueryMysqlRepositoryTest.php index e089d9a..d6c5ed2 100644 --- a/tests/Unit/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsAdminQueryMysqlRepositoryTest.php +++ b/tests/Unit/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsAdminQueryMysqlRepositoryTest.php @@ -31,9 +31,9 @@ protected function setUp(): void $this->repository = new DeliveryOperationsAdminQueryMysqlRepository($this->pdo); } - public function testItAdaptsResultCorrectly(): void + public function testItAdaptsResultCorrectlyWithLimitsMaxAndMinPaginationGates(): void { - $this->pdo->expects($this->exactly(3)) + $this->pdo->expects($this->exactly(9)) // 3 requests * 3 prepares (1 data + 2 count) ->method('prepare') ->willReturnCallback(function (string $sql) { if (!str_contains($sql, 'COUNT(*)')) { @@ -51,20 +51,70 @@ public function testItAdaptsResultCorrectly(): void $this->statement->method('execute')->willReturn(true); $this->statement->method('bindValue')->willReturn(true); - $this->countStatement->expects($this->exactly(2)) + $this->countStatement->expects($this->exactly(6)) ->method('columnCount') ->willReturn(1); - $this->countStatement->expects($this->exactly(4)) + $this->countStatement->expects($this->exactly(12)) ->method('fetch') - ->willReturnOnConsecutiveCalls(['COUNT(*)' => 1], false, ['COUNT(*)' => 1], false); + ->willReturnOnConsecutiveCalls( + ['COUNT(*)' => 1], false, ['COUNT(*)' => 1], false, + ['COUNT(*)' => 1], false, ['COUNT(*)' => 1], false, + ['COUNT(*)' => 1], false, ['COUNT(*)' => 1], false + ); $this->countStatement->method('errorCode')->willReturn('00000'); $this->statement->method('errorCode')->willReturn('00000'); - $this->statement->expects($this->exactly(2)) + $this->statement->expects($this->exactly(6)) ->method('fetch') ->willReturnOnConsecutiveCalls( + [ + 'id' => '1', + 'event_id' => 'evt-1', + 'channel' => 'chan-1', + 'operation_type' => 'op-1', + 'actor_type' => 'act-1', + 'actor_id' => '42', + 'target_type' => 'tar-1', + 'target_id' => '43', + 'status' => 'stat-1', + 'attempt_no' => '0', + 'scheduled_at' => null, + 'completed_at' => null, + 'correlation_id' => null, + 'request_id' => null, + 'provider' => null, + 'provider_message_id' => null, + 'error_code' => null, + 'error_message' => null, + 'metadata' => null, + 'occurred_at' => '2023-01-01 00:00:00.000000', + ], + false, + [ + 'id' => '1', + 'event_id' => 'evt-1', + 'channel' => 'chan-1', + 'operation_type' => 'op-1', + 'actor_type' => 'act-1', + 'actor_id' => '42', + 'target_type' => 'tar-1', + 'target_id' => '43', + 'status' => 'stat-1', + 'attempt_no' => '0', + 'scheduled_at' => null, + 'completed_at' => null, + 'correlation_id' => null, + 'request_id' => null, + 'provider' => null, + 'provider_message_id' => null, + 'error_code' => null, + 'error_message' => null, + 'metadata' => null, + 'occurred_at' => '2023-01-01 00:00:00.000000', + ], + false, [ 'id' => '1', 'event_id' => 'evt-1', @@ -90,21 +140,23 @@ public function testItAdaptsResultCorrectly(): void false ); - $request = new DeliveryOperationsAdminQueryRequestDTO(page: 1, perPage: 20); - $result = $this->repository->paginate($request); + // First request to verify min clamp to page=1, perPage=1 + $requestMin = new DeliveryOperationsAdminQueryRequestDTO(page: 0, perPage: 0); + $resultMin = $this->repository->paginate($requestMin); + $this->assertEquals(1, $resultMin->page); + $this->assertEquals(1, $resultMin->perPage); - $this->assertEquals(1, $result->page); - $this->assertEquals(20, $result->perPage); - $this->assertEquals(1, $result->total); - $this->assertEquals(1, $result->filtered); - $this->assertEquals(1, $result->totalPages); - $this->assertFalse($result->hasNext); - $this->assertFalse($result->hasPrevious); - $this->assertEquals('occurred_at', $result->sortBy); - $this->assertEquals('DESC', $result->sortDirection); - - $this->assertCount(1, $result->items); - $this->assertEquals(1, $result->items[0]->id); + // Second request to verify max clamp to perPage=200 + $requestMax = new DeliveryOperationsAdminQueryRequestDTO(page: 1, perPage: 500); + $resultMax = $this->repository->paginate($requestMax); + $this->assertEquals(1, $resultMax->page); + $this->assertEquals(200, $resultMax->perPage); + + // Third request to verify defaults + $requestDefault = new DeliveryOperationsAdminQueryRequestDTO(); + $resultDefault = $this->repository->paginate($requestDefault); + $this->assertEquals(1, $resultDefault->page); + $this->assertEquals(20, $resultDefault->perPage); } public function testItTranslatesPDOExceptionToStorageException(): void @@ -188,4 +240,53 @@ public function testItWrapsMapperFailure(): void $this->repository->paginate($request); } + + public function testItNeverCallsTransactionMethods(): void + { + $this->pdo->expects($this->never())->method('beginTransaction'); + $this->pdo->expects($this->never())->method('commit'); + $this->pdo->expects($this->never())->method('rollBack'); + + $this->pdo->expects($this->exactly(3)) + ->method('prepare') + ->willReturnCallback(function (string $sql) { + if (str_contains($sql, 'COUNT(*)')) { + return $this->countStatement; + } + return $this->statement; + }); + + $this->countStatement->method('execute')->willReturn(true); + $this->countStatement->method('bindValue')->willReturn(true); + $this->statement->method('execute')->willReturn(true); + $this->statement->method('bindValue')->willReturn(true); + $this->countStatement->method('columnCount')->willReturn(1); + $this->countStatement->method('errorCode')->willReturn('00000'); + $this->statement->method('errorCode')->willReturn('00000'); + $this->countStatement->method('fetch')->willReturnOnConsecutiveCalls(['COUNT(*)' => 1], false, ['COUNT(*)' => 1], false); + $this->statement->method('fetch')->willReturn(false); // return 0 items + + $request = new DeliveryOperationsAdminQueryRequestDTO(page: 1, perPage: 20); + $result = $this->repository->paginate($request); + $this->assertCount(0, $result->items); + } + + public function testItNeverCallsTransactionMethodsOnFailure(): void + { + $this->pdo->expects($this->never())->method('beginTransaction'); + $this->pdo->expects($this->never())->method('commit'); + $this->pdo->expects($this->never())->method('rollBack'); + + $this->pdo->expects($this->once()) + ->method('prepare') + ->willThrowException(new \PDOException('Failure')); + + $request = new DeliveryOperationsAdminQueryRequestDTO(page: 1, perPage: 20); + try { + $this->repository->paginate($request); + $this->fail('Expected Exception'); + } catch (DeliveryOperationsStorageException) { + // expected + } + } } diff --git a/tests/Unit/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsRowMapperTest.php b/tests/Unit/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsRowMapperTest.php index fecc25a..ec9cec5 100644 --- a/tests/Unit/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsRowMapperTest.php +++ b/tests/Unit/DeliveryOperations/Infrastructure/Mysql/DeliveryOperationsRowMapperTest.php @@ -99,6 +99,22 @@ public function testItMapsInvalidJsonToNull(): void $this->assertNull($dto->metadata); } + public function testItMapsNumericKeyJsonToNull(): void + { + $dto = $this->mapper->map([ + 'metadata' => '{"1": "x"}' + ]); + $this->assertNull($dto->metadata); + } + + public function testItMapsMixedKeyJsonToNull(): void + { + $dto = $this->mapper->map([ + 'metadata' => '{"a": "x", "1": "y"}' + ]); + $this->assertNull($dto->metadata); + } + public function testItMapsEmptyJsonToNull(): void { $dto = $this->mapper->map([