StatsControllerTest.php 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Admin;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Domain\Ip\IpAddress;
  7. use App\Tests\Integration\Support\AppTestCase;
  8. use Doctrine\DBAL\ParameterType;
  9. /**
  10. * Covers the dashboard endpoint shape: scalar counters, by-hour
  11. * histogram, top reporters/categories, jobs status. Cache TTL is 30s
  12. * but the test issues each request with fresh state — we don't
  13. * exercise the cache window directly here.
  14. */
  15. final class StatsControllerTest extends AppTestCase
  16. {
  17. public function testDashboardEmptyShape(): void
  18. {
  19. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  20. $response = $this->request('GET', '/api/v1/admin/stats/dashboard', [
  21. 'Authorization' => 'Bearer ' . $token,
  22. ]);
  23. self::assertSame(200, $response->getStatusCode());
  24. $body = $this->decode($response);
  25. foreach (['active_blocks', 'manual_blocks_count', 'allowlist_count', 'reports_24h'] as $k) {
  26. self::assertArrayHasKey($k, $body);
  27. self::assertSame(0, $body[$k]);
  28. }
  29. self::assertSame([], $body['reports_24h_by_hour']);
  30. self::assertSame([], $body['top_reporters_24h']);
  31. self::assertSame([], $body['top_categories_24h']);
  32. // bans_by_day_7d always emits 8 days (today + the 7 prior, zero-filled).
  33. self::assertCount(8, $body['bans_by_day_7d']);
  34. foreach ($body['bans_by_day_7d'] as $row) {
  35. self::assertArrayHasKey('day', $row);
  36. self::assertArrayHasKey('count', $row);
  37. self::assertSame(0, $row['count']);
  38. }
  39. self::assertSame('moderate', $body['reference_policy']);
  40. }
  41. public function testDashboardCountsFromSeededReports(): void
  42. {
  43. // Use a fresh class-level container/session per test (setUp does this).
  44. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  45. $reporterId = $this->createReporter('rep-stats');
  46. for ($i = 0; $i < 3; ++$i) {
  47. $this->seedReport(sprintf('203.0.113.%d', 10 + $i), 'brute_force', $reporterId);
  48. }
  49. $this->seedReport('203.0.113.20', 'spam', $reporterId);
  50. $body = $this->decode($this->request('GET', '/api/v1/admin/stats/dashboard', [
  51. 'Authorization' => 'Bearer ' . $token,
  52. ]));
  53. self::assertSame(4, $body['reports_24h']);
  54. self::assertNotEmpty($body['reports_24h_by_hour']);
  55. self::assertSame('rep-stats', $body['top_reporters_24h'][0]['name']);
  56. self::assertSame(4, $body['top_reporters_24h'][0]['count']);
  57. $catSlugs = array_map(static fn (array $r): string => $r['slug'], $body['top_categories_24h']);
  58. self::assertContains('brute_force', $catSlugs);
  59. self::assertContains('spam', $catSlugs);
  60. }
  61. public function testManualBlocksAndAllowlistCounters(): void
  62. {
  63. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  64. $this->db->insert('manual_blocks', [
  65. 'kind' => 'ip',
  66. 'ip_bin' => IpAddress::fromString('203.0.113.5')->binary(),
  67. 'reason' => 'x',
  68. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  69. $this->db->insert('allowlist', [
  70. 'kind' => 'ip',
  71. 'ip_bin' => IpAddress::fromString('203.0.113.6')->binary(),
  72. 'reason' => 'y',
  73. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  74. $body = $this->decode($this->request('GET', '/api/v1/admin/stats/dashboard', [
  75. 'Authorization' => 'Bearer ' . $token,
  76. ]));
  77. self::assertSame(1, $body['manual_blocks_count']);
  78. self::assertSame(1, $body['allowlist_count']);
  79. // The manual block we just inserted (created_at = NOW per DB
  80. // default) should land in today's bucket of bans_by_day_7d.
  81. self::assertCount(8, $body['bans_by_day_7d']);
  82. $totalBans = array_sum(array_map(static fn (array $r): int => (int) $r['count'], $body['bans_by_day_7d']));
  83. self::assertSame(1, $totalBans);
  84. }
  85. private function seedReport(string $ip, string $categorySlug, int $reporterId): void
  86. {
  87. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
  88. $ipObj = IpAddress::fromString($ip);
  89. $stmt = $this->db->prepare(
  90. 'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) '
  91. . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)'
  92. );
  93. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  94. $stmt->bindValue('t', $ipObj->text());
  95. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  96. $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
  97. $stmt->bindValue('w', '1.00');
  98. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  99. $stmt->executeStatement();
  100. }
  101. }