diff --git a/backend/config/services.yaml b/backend/config/services.yaml
index f8c7cb4..dad4740 100644
--- a/backend/config/services.yaml
+++ b/backend/config/services.yaml
@@ -41,9 +41,9 @@ services:
App\Auth\Permissions:
arguments: ['%kernel.project_dir%', 'config/permissions.yaml']
- App\Instrumentation\DataCollector\PermissionVoterCollector:
+ App\Instrumentation\DataCollector\PermissionVotesCollector:
tags:
- - { name: data_collector, template: 'profiler/permission_voter_collector.html.twig', id: 'app.permission_voter_collector' }
+ - { name: data_collector, template: 'profiler/permission_votes.html.twig', id: 'app.permission_votes_collector' }
App\Instrumentation\DataCollector\ArchitectureCollector:
arguments:
diff --git a/backend/src/Auth/PermissionVoter.php b/backend/src/Auth/PermissionVoter.php
index e9bf8a6..4f1c0b6 100644
--- a/backend/src/Auth/PermissionVoter.php
+++ b/backend/src/Auth/PermissionVoter.php
@@ -5,7 +5,7 @@
namespace App\Auth;
use App\Game\CurrentUserInterface;
-use App\Instrumentation\DataCollector\PermissionVoterCollector;
+use App\Instrumentation\DataCollector\PermissionVotesCollector;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
@@ -17,7 +17,7 @@ final class PermissionVoter extends Voter
{
public function __construct(
private readonly CurrentUserInterface $currentUser,
- private readonly PermissionVoterCollector $collector,
+ private readonly PermissionVotesCollector $collector,
) {
}
diff --git a/backend/src/Instrumentation/DataCollector/PermissionVoterCollector.php b/backend/src/Instrumentation/DataCollector/PermissionVotesCollector.php
similarity index 96%
rename from backend/src/Instrumentation/DataCollector/PermissionVoterCollector.php
rename to backend/src/Instrumentation/DataCollector/PermissionVotesCollector.php
index e677526..46d0033 100644
--- a/backend/src/Instrumentation/DataCollector/PermissionVoterCollector.php
+++ b/backend/src/Instrumentation/DataCollector/PermissionVotesCollector.php
@@ -8,7 +8,7 @@
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
-class PermissionVoterCollector extends DataCollector
+class PermissionVotesCollector extends DataCollector
{
public function addVoterCall(string $attribute, mixed $subject, bool $hasPermission): void
{
@@ -98,6 +98,6 @@ public function reset(): void
public function getName(): string
{
- return 'app.permission_voter_collector';
+ return 'app.permission_votes_collector';
}
}
diff --git a/backend/templates/profiler/icon.svg b/backend/templates/profiler/lock.svg
similarity index 100%
rename from backend/templates/profiler/icon.svg
rename to backend/templates/profiler/lock.svg
diff --git a/backend/templates/profiler/permission_voter_collector.html.twig b/backend/templates/profiler/permission_votes.html.twig
similarity index 88%
rename from backend/templates/profiler/permission_voter_collector.html.twig
rename to backend/templates/profiler/permission_votes.html.twig
index 1ad6e10..f9557b1 100644
--- a/backend/templates/profiler/permission_voter_collector.html.twig
+++ b/backend/templates/profiler/permission_votes.html.twig
@@ -1,15 +1,15 @@
-{# templates/profiler/permission_voter_collector.html.twig #}
+{# templates/profiler/permission_votes.html.twig #}
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% set icon %}
- {{ include('profiler/icon.svg') }}
+ {{ include('profiler/lock.svg') }}
{{ collector.voterCalls|length }}
{% endset %}
{% set text %}
- Voter Calls
+ Permission votes
{{ collector.voterCalls|length }}
{% endset %}
@@ -19,18 +19,18 @@
{% block menu %}
- {{ include('profiler/icon.svg') }}
- Voter
+ {{ include('profiler/lock.svg') }}
+ Votes
{{ collector.voterCalls|length }}
{% endblock %}
{% block panel %}
- PermissionVoter Calls
+ Permission votes
{% if collector.voterCalls is empty %}
-
No calls to PermissionVoter during this request.
+
No votes yet.
{% else %}
diff --git a/backend/tests/Unit/Instrumentation/DataCollector/PermissionVotesCollectorTest.php b/backend/tests/Unit/Instrumentation/DataCollector/PermissionVotesCollectorTest.php
new file mode 100644
index 0000000..7ef6f2b
--- /dev/null
+++ b/backend/tests/Unit/Instrumentation/DataCollector/PermissionVotesCollectorTest.php
@@ -0,0 +1,451 @@
+getName());
+ }
+
+ #[Test]
+ public function should_return_empty_voter_calls_initially(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $calls = $collector->getVoterCalls();
+
+ self::assertSame([], $calls);
+ }
+
+ #[Test]
+ public function should_add_voter_call_with_string_subject(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('VIEW', 'string', true);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ self::assertSame('VIEW', $calls[0]['attribute']);
+ self::assertSame('string', $calls[0]['subject']);
+ self::assertTrue($calls[0]['hasPermission']);
+ }
+
+ #[Test]
+ public function should_add_voter_call_with_integer_subject(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('EDIT', 123, false);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ self::assertSame('EDIT', $calls[0]['attribute']);
+ self::assertSame('integer', $calls[0]['subject']);
+ self::assertFalse($calls[0]['hasPermission']);
+ }
+
+ #[Test]
+ public function should_add_voter_call_with_array_subject(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('DELETE', [], true);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ self::assertSame('DELETE', $calls[0]['attribute']);
+ self::assertSame('array', $calls[0]['subject']);
+ self::assertTrue($calls[0]['hasPermission']);
+ }
+
+ #[Test]
+ public function should_add_voter_call_with_object_subject(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $object = new \stdClass();
+
+ $collector->addVoterCall('APPROVE', $object, true);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ self::assertSame('APPROVE', $calls[0]['attribute']);
+ self::assertSame('stdClass', $calls[0]['subject']);
+ self::assertTrue($calls[0]['hasPermission']);
+ }
+
+ #[Test]
+ public function should_add_multiple_voter_calls(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('EDIT', 'string', false);
+ $collector->addVoterCall('DELETE', 'array', true);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(3, $calls);
+ self::assertSame('VIEW', $calls[0]['attribute']);
+ self::assertSame('EDIT', $calls[1]['attribute']);
+ self::assertSame('DELETE', $calls[2]['attribute']);
+ }
+
+ #[Test]
+ public function should_collect_does_nothing(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+
+ $collector->collect(Request::create('/'), new Response());
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ }
+
+ #[Test]
+ public function should_collect_with_exception_does_nothing(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+
+ $exception = new \Exception('Test exception');
+ $collector->collect(Request::create('/'), new Response(), $exception);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ }
+
+ #[Test]
+ public function should_reset_voter_calls(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('EDIT', 'string', false);
+
+ self::assertCount(2, $collector->getVoterCalls());
+
+ $collector->reset();
+
+ self::assertCount(0, $collector->getVoterCalls());
+ }
+
+ #[Test]
+ public function should_get_attribute_stats_with_single_attribute(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('VIEW', 'integer', true);
+ $collector->addVoterCall('VIEW', 'array', false);
+
+ $stats = $collector->getAttributeStats();
+
+ self::assertCount(1, $stats);
+ self::assertSame(3, $stats['VIEW']);
+ }
+
+ #[Test]
+ public function should_get_attribute_stats_with_multiple_attributes(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('VIEW', 'integer', true);
+ $collector->addVoterCall('EDIT', 'string', false);
+ $collector->addVoterCall('EDIT', 'integer', false);
+ $collector->addVoterCall('EDIT', 'array', false);
+ $collector->addVoterCall('DELETE', 'string', true);
+
+ $stats = $collector->getAttributeStats();
+
+ self::assertCount(3, $stats);
+ self::assertSame(2, $stats['VIEW']);
+ self::assertSame(3, $stats['EDIT']);
+ self::assertSame(1, $stats['DELETE']);
+ }
+
+ #[Test]
+ public function should_get_empty_attribute_stats_when_no_calls(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $stats = $collector->getAttributeStats();
+
+ self::assertSame([], $stats);
+ }
+
+ #[Test]
+ public function should_get_chart_labels_matching_attributes(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('EDIT', 'string', false);
+ $collector->addVoterCall('DELETE', 'array', true);
+
+ $labels = $collector->getChartLabels();
+
+ self::assertCount(3, $labels);
+ self::assertContains('VIEW', $labels);
+ self::assertContains('EDIT', $labels);
+ self::assertContains('DELETE', $labels);
+ }
+
+ #[Test]
+ public function should_get_empty_chart_labels_when_no_calls(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $labels = $collector->getChartLabels();
+
+ self::assertSame([], $labels);
+ }
+
+ #[Test]
+ public function should_get_chart_data_matching_attribute_counts(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('VIEW', 'integer', true);
+ $collector->addVoterCall('EDIT', 'string', false);
+ $collector->addVoterCall('EDIT', 'integer', false);
+ $collector->addVoterCall('EDIT', 'array', false);
+
+ $data = $collector->getChartData();
+
+ self::assertCount(2, $data);
+ // Data values should match the counts (order may vary due to array_keys/array_values)
+ sort($data);
+ self::assertSame([2, 3], $data);
+ }
+
+ #[Test]
+ public function should_get_empty_chart_data_when_no_calls(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $data = $collector->getChartData();
+
+ self::assertSame([], $data);
+ }
+
+ #[Test]
+ public function should_handle_null_subject(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('VIEW', null, true);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ self::assertSame('NULL', $calls[0]['subject']);
+ }
+
+ #[Test]
+ public function should_handle_boolean_subject(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('VIEW', true, false);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ self::assertSame('boolean', $calls[0]['subject']);
+ }
+
+ #[Test]
+ public function should_handle_float_subject(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('VIEW', 3.14, true);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ self::assertSame('double', $calls[0]['subject']);
+ }
+
+ #[Test]
+ public function should_handle_resource_subject(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $stream = fopen('php://memory', 'r');
+
+ if (false === $stream) {
+ self::markTestSkipped('Could not create stream resource');
+ }
+
+ try {
+ $collector->addVoterCall('VIEW', $stream, true);
+
+ $calls = $collector->getVoterCalls();
+ self::assertCount(1, $calls);
+ self::assertSame('resource', $calls[0]['subject']);
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ #[Test]
+ public function should_track_permission_grants_and_denials(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('VIEW', 'integer', false);
+ $collector->addVoterCall('VIEW', 'array', true);
+
+ $calls = $collector->getVoterCalls();
+
+ $granted = array_filter($calls, static fn ($call) => $call['hasPermission']);
+ $denied = array_filter($calls, static fn ($call) => !$call['hasPermission']);
+
+ self::assertCount(2, $granted);
+ self::assertCount(1, $denied);
+ }
+
+ #[Test]
+ public function should_preserve_insertion_order_in_voter_calls(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $attributes = ['FIRST', 'SECOND', 'THIRD', 'FOURTH', 'FIFTH'];
+ foreach ($attributes as $attr) {
+ $collector->addVoterCall($attr, 'string', true);
+ }
+
+ $calls = $collector->getVoterCalls();
+
+ foreach ($attributes as $index => $attr) {
+ self::assertSame($attr, $calls[$index]['attribute']);
+ }
+ }
+
+ #[Test]
+ public function should_count_same_attribute_with_different_subjects(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('VIEW', 'integer', true);
+ $collector->addVoterCall('VIEW', new \stdClass(), false);
+ $collector->addVoterCall('VIEW', [], true);
+
+ $stats = $collector->getAttributeStats();
+
+ self::assertSame(4, $stats['VIEW']);
+ }
+
+ #[Test]
+ public function should_handle_same_attribute_and_subject_type_different_permissions(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('VIEW', 'string', false);
+ $collector->addVoterCall('VIEW', 'string', true);
+
+ $stats = $collector->getAttributeStats();
+
+ self::assertSame(3, $stats['VIEW']);
+ }
+
+ #[Test]
+ public function should_get_chart_labels_and_data_in_consistent_order(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('VIEW', 'integer', true);
+ $collector->addVoterCall('EDIT', 'string', false);
+ $collector->addVoterCall('EDIT', 'integer', false);
+ $collector->addVoterCall('EDIT', 'array', false);
+ $collector->addVoterCall('DELETE', 'string', true);
+
+ $labels = $collector->getChartLabels();
+ $data = $collector->getChartData();
+
+ self::assertCount(count($labels), $data);
+ self::assertCount(3, $labels);
+ self::assertCount(3, $data);
+ }
+
+ #[Test]
+ public function should_reset_clears_all_data(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+ $collector->addVoterCall('EDIT', 'integer', false);
+ $collector->addVoterCall('DELETE', 'array', true);
+
+ self::assertNotEmpty($collector->getVoterCalls());
+ self::assertNotEmpty($collector->getAttributeStats());
+ self::assertNotEmpty($collector->getChartLabels());
+ self::assertNotEmpty($collector->getChartData());
+
+ $collector->reset();
+
+ self::assertEmpty($collector->getVoterCalls());
+ self::assertEmpty($collector->getAttributeStats());
+ self::assertEmpty($collector->getChartLabels());
+ self::assertEmpty($collector->getChartData());
+ }
+
+ #[Test]
+ public function should_add_calls_after_reset(): void
+ {
+ $collector = new PermissionVotesCollector();
+ $collector->addVoterCall('VIEW', 'string', true);
+
+ $collector->reset();
+ $collector->addVoterCall('EDIT', 'integer', false);
+
+ $calls = $collector->getVoterCalls();
+
+ self::assertCount(1, $calls);
+ self::assertSame('EDIT', $calls[0]['attribute']);
+ }
+
+ #[Test]
+ public function should_handle_unicode_attribute_names(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('ANZEIGEN', 'string', true);
+ $collector->addVoterCall('BEARBEITEN', 'string', false);
+ $collector->addVoterCall('LÖSCHEN', 'string', true);
+
+ $stats = $collector->getAttributeStats();
+
+ self::assertCount(3, $stats);
+ self::assertSame(1, $stats['ANZEIGEN']);
+ self::assertSame(1, $stats['BEARBEITEN']);
+ self::assertSame(1, $stats['LÖSCHEN']);
+ }
+
+ #[Test]
+ public function should_handle_case_sensitive_attribute_names(): void
+ {
+ $collector = new PermissionVotesCollector();
+
+ $collector->addVoterCall('view', 'string', true);
+ $collector->addVoterCall('VIEW', 'string', false);
+ $collector->addVoterCall('View', 'string', true);
+
+ $stats = $collector->getAttributeStats();
+
+ self::assertCount(3, $stats);
+ self::assertSame(1, $stats['view']);
+ self::assertSame(1, $stats['VIEW']);
+ self::assertSame(1, $stats['View']);
+ }
+}