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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
76 changes: 76 additions & 0 deletions src/Panel/User/UserRbacRow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Panel\User;

use function is_int;
use function is_string;

/**
* Represents one RBAC item row (role or permission) in the User panel detail view.
*
* Usage example:
*
* ```php
* $row = \PHPForge\Debug\Panel\User\UserRbacRow::fromArray($rawItem);
* echo $row->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<array-key, mixed> $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),
);
}
}
129 changes: 129 additions & 0 deletions tests/Panel/User/UserRbacRowTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Tests\Panel\User;

use PHPForge\Debug\Panel\User\UserRbacRow;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;

/**
* Unit tests for {@see UserRbacRow} covering hydration from the normalized adapter array shape: string coercion for
* textual fields, timestamp narrowing (`int`, numeric-string, non-numeric) and missing-key defaults.
*/
#[Group('panel')]
#[Group('user')]
final class UserRbacRowTest extends TestCase
{
public function testConstructorExposesAllPropertiesVerbatim(): void
{
$row = new UserRbacRow(
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 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`.');
}
}
Loading