From ddac2d3c58d599b16b4fc11613c573b0e33e8774 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 18 Aug 2026 18:03:09 -0400 Subject: [PATCH] feat(panel): add `UserRbacRow` typed view-model so adapters render RBAC role and permission rows from a single normalized shape. --- CHANGELOG.md | 1 + src/Panel/User/UserRbacRow.php | 76 ++++++++++++++++ tests/Panel/User/UserRbacRowTest.php | 129 +++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 src/Panel/User/UserRbacRow.php create mode 100644 tests/Panel/User/UserRbacRowTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c6fb779..3c3a266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,3 +15,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - test: strengthen mutation coverage and clear PHPStan's result cache before static mutation analysis. - feat(router): implement Router panel with Current Route and Rules sections. - fix(toolbar): follow debug tags through adapter query URLs and enforce complete JavaScript mutation coverage. +- feat(panel): add `UserRbacRow` typed view-model so adapters render RBAC role and permission rows from a single normalized shape. diff --git a/src/Panel/User/UserRbacRow.php b/src/Panel/User/UserRbacRow.php new file mode 100644 index 0000000..fcb2961 --- /dev/null +++ b/src/Panel/User/UserRbacRow.php @@ -0,0 +1,76 @@ +name; + * ``` + */ +final readonly class UserRbacRow +{ + /** + * @param string $name Item name, unique within the hierarchy. + * @param string $description Human-readable description of the item's purpose. + * @param string $ruleName Name of the rule associated with the item, or an empty string when none. + * @param string $data Serialized arbitrary data attached to the item, or an empty string when absent. + * @param int|null $createdAt UNIX timestamp of item creation, or `null` when not recorded. + * @param int|null $updatedAt UNIX timestamp of the last item update, or `null` when not recorded. + */ + public function __construct( + public string $name, + public string $description, + public string $ruleName, + public string $data, + public int|null $createdAt, + public int|null $updatedAt, + ) {} + + /** + * Builds a row from the normalized array shape produced by RBAC adapters. + * + * Usage example: + * + * ```php + * $row = \PHPForge\Debug\Panel\User\UserRbacRow::fromArray([ + * 'name' => 'admin', + * 'description' => 'Administrator', + * 'ruleName' => '', + * 'data' => '', + * 'createdAt' => 1700000000, + * 'updatedAt' => 1700000001, + * ]); + * ``` + * + * @param array $row Associative array with keys `name`, `description`, `ruleName`, `data`, + * `createdAt`, and `updatedAt`. + */ + public static function fromArray(array $row): self + { + $name = $row['name'] ?? ''; + $description = $row['description'] ?? ''; + $ruleName = $row['ruleName'] ?? ''; + $data = $row['data'] ?? ''; + $createdAt = $row['createdAt'] ?? null; + $updatedAt = $row['updatedAt'] ?? null; + + return new self( + name: is_string($name) ? $name : '', + description: is_string($description) ? $description : '', + ruleName: is_string($ruleName) ? $ruleName : '', + data: is_string($data) ? $data : '', + createdAt: is_int($createdAt) ? $createdAt : (is_numeric($createdAt) ? (int) $createdAt : null), + updatedAt: is_int($updatedAt) ? $updatedAt : (is_numeric($updatedAt) ? (int) $updatedAt : null), + ); + } +} diff --git a/tests/Panel/User/UserRbacRowTest.php b/tests/Panel/User/UserRbacRowTest.php new file mode 100644 index 0000000..4904d44 --- /dev/null +++ b/tests/Panel/User/UserRbacRowTest.php @@ -0,0 +1,129 @@ +name, 'Name must be exposed verbatim.'); + self::assertSame('Administrator', $row->description, 'Description must be exposed verbatim.'); + self::assertSame('isAdmin', $row->ruleName, 'Rule name must be exposed verbatim.'); + self::assertSame('{"scope":"all"}', $row->data, 'Data must be exposed verbatim.'); + self::assertSame(1_700_000_000, $row->createdAt, 'Created-at timestamp must be exposed verbatim.'); + self::assertSame(1_700_000_001, $row->updatedAt, 'Updated-at timestamp must be exposed verbatim.'); + } + + public function testFromArrayCastsNumericStringTimestampsToInt(): void + { + $row = UserRbacRow::fromArray( + [ + 'name' => 'editor', + 'createdAt' => '1700000000', + 'updatedAt' => '1700000001', + ], + ); + + self::assertSame(1_700_000_000, $row->createdAt, 'Numeric string must be cast to `int`.'); + self::assertSame(1_700_000_001, $row->updatedAt, 'Numeric string must be cast to `int`.'); + } + + public function testFromArrayCoercesNonStringTextualFieldsToEmptyStrings(): void + { + $row = UserRbacRow::fromArray( + [ + 'name' => 42, + 'description' => ['nested'], + 'ruleName' => null, + 'data' => 3.14, + 'createdAt' => 1_700_000_000, + 'updatedAt' => 1_700_000_001, + ], + ); + + self::assertSame('', $row->name, 'Non-string name must collapse to an empty `string`.'); + self::assertSame('', $row->description, 'Non-string description must collapse to an empty `string`.'); + self::assertSame('', $row->ruleName, 'Non-string rule name must collapse to an empty `string`.'); + self::assertSame('', $row->data, 'Non-string data must collapse to an empty `string`.'); + } + + public function testFromArrayDefaultsMissingKeysToEmptyStringsAndNullTimestamps(): void + { + $row = UserRbacRow::fromArray([]); + + self::assertSame('', $row->name, 'Missing name must default to an empty `string`.'); + self::assertSame('', $row->description, 'Missing description must default to an empty `string`.'); + self::assertSame('', $row->ruleName, 'Missing rule name must default to an empty `string`.'); + self::assertSame('', $row->data, 'Missing data must default to an empty `string`.'); + self::assertNull($row->createdAt, 'Missing created-at must default to `null`.'); + self::assertNull($row->updatedAt, 'Missing updated-at must default to `null`.'); + } + + public function testFromArrayHydratesAllFieldsFromCompleteRow(): void + { + $row = UserRbacRow::fromArray( + [ + 'name' => 'admin', + 'description' => 'Administrator', + 'ruleName' => 'isAdmin', + 'data' => '{"scope":"all"}', + 'createdAt' => 1_700_000_000, + 'updatedAt' => 1_700_000_001, + ], + ); + + self::assertSame('admin', $row->name, 'Name must be hydrated.'); + self::assertSame('Administrator', $row->description, 'Description must be hydrated.'); + self::assertSame('isAdmin', $row->ruleName, 'Rule name must be hydrated.'); + self::assertSame('{"scope":"all"}', $row->data, 'Data must be hydrated.'); + self::assertSame(1_700_000_000, $row->createdAt, 'Integer created-at must pass through unchanged.'); + self::assertSame(1_700_000_001, $row->updatedAt, 'Integer updated-at must pass through unchanged.'); + } + + public function testFromArrayRejectsNonNumericTimestamps(): void + { + $row = UserRbacRow::fromArray( + [ + 'createdAt' => 'yesterday', + 'updatedAt' => [], + ], + ); + + self::assertNull($row->createdAt, 'Non-numeric created-at must collapse to `null`.'); + self::assertNull($row->updatedAt, 'Non-numeric updated-at must collapse to `null`.'); + } + + public function testFromArrayTruncatesFloatTimestampsToInt(): void + { + $row = UserRbacRow::fromArray( + [ + 'createdAt' => 1_700_000_000.9, + 'updatedAt' => 1_700_000_001.9, + ], + ); + + self::assertSame(1_700_000_000, $row->createdAt, 'Float created-at must be truncated to `int`.'); + self::assertSame(1_700_000_001, $row->updatedAt, 'Float updated-at must be truncated to `int`.'); + } +}