1
0

RecomputeScoresJobTest.php 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Jobs;
  4. use App\Application\Jobs\RecomputeScoresJob;
  5. use App\Domain\Ip\IpAddress;
  6. use App\Domain\Jobs\JobContext;
  7. use App\Domain\Reputation\PairScorer;
  8. use App\Domain\Time\FixedClock;
  9. use App\Infrastructure\Category\CategoryRepository;
  10. use App\Infrastructure\Reputation\IpScoreRepository;
  11. use App\Infrastructure\Reputation\ReportRepository;
  12. use App\Tests\Integration\Support\AppTestCase;
  13. use Doctrine\DBAL\ParameterType;
  14. use Monolog\Handler\NullHandler;
  15. use Monolog\Logger;
  16. /**
  17. * Bulk recompute over real reports rows. The headline scenario is the M05
  18. * acceptance: clock-forward 30 days between two reports with a 14-day
  19. * exponential half-life and verify the score matches `0.5^(30/14) +
  20. * 0.5^(0/14) = 1 + ~0.226 = ~1.226`.
  21. */
  22. final class RecomputeScoresJobTest extends AppTestCase
  23. {
  24. public function testFullModeAppliesDecayAcross30DayWindow(): void
  25. {
  26. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  27. // 30 days ago: a report
  28. $reporterId = $this->createReporter('decay-test');
  29. $ip = IpAddress::fromString('203.0.113.42');
  30. /** @var int $catId */
  31. $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='brute_force'");
  32. self::assertGreaterThan(0, $catId);
  33. $thirtyDaysAgo = $clock->now()->modify('-30 days');
  34. $this->insertReport($ip, $catId, $reporterId, 1.0, $thirtyDaysAgo);
  35. // and a fresh report just now
  36. $this->insertReport($ip, $catId, $reporterId, 1.0, $clock->now());
  37. // Pre-existing ip_scores row so the recompute touches it. (In real
  38. // life the synchronous ingest path would have created this; for an
  39. // isolated test we simulate with a stale row.)
  40. $this->db->insert('ip_scores', [
  41. 'ip_bin' => $ip->binary(),
  42. 'ip_text' => $ip->text(),
  43. 'category_id' => $catId,
  44. 'score' => '0.0000',
  45. 'report_count_30d' => 0,
  46. 'last_report_at' => $thirtyDaysAgo->format('Y-m-d H:i:s'),
  47. 'recomputed_at' => $thirtyDaysAgo->format('Y-m-d H:i:s'),
  48. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  49. $job = $this->buildJob($clock);
  50. $logger = new Logger('test');
  51. $logger->pushHandler(new NullHandler());
  52. $result = $job->run(new JobContext($clock, $logger, ['full' => true]));
  53. self::assertGreaterThan(0, $result->itemsProcessed);
  54. $newScore = (float) $this->db->fetchOne(
  55. 'SELECT score FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
  56. ['bin' => $ip->binary(), 'cat' => $catId],
  57. ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
  58. );
  59. // Expected: 1.0 (fresh) + 0.5^(30/14) (30-day-old) ≈ 1.2266.
  60. $expected = 1.0 + 0.5 ** (30.0 / 14.0);
  61. self::assertEqualsWithDelta($expected, $newScore, 0.001);
  62. }
  63. public function testIncrementalModePicksUpRecentlyTouchedPairs(): void
  64. {
  65. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  66. $reporterId = $this->createReporter('inc-test');
  67. $ip = IpAddress::fromString('198.51.100.7');
  68. /** @var int $catId */
  69. $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='scanner'");
  70. $this->insertReport($ip, $catId, $reporterId, 1.0, $clock->now()->modify('-2 minutes'));
  71. $job = $this->buildJob($clock);
  72. $logger = new Logger('test');
  73. $logger->pushHandler(new NullHandler());
  74. $result = $job->run(new JobContext($clock, $logger, []));
  75. self::assertGreaterThanOrEqual(1, $result->itemsProcessed);
  76. $score = (float) $this->db->fetchOne(
  77. 'SELECT score FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
  78. ['bin' => $ip->binary(), 'cat' => $catId],
  79. ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
  80. );
  81. // Fresh report → score ~ 1.0
  82. self::assertEqualsWithDelta(1.0, $score, 0.01);
  83. }
  84. public function testDropsStaleNearZeroPairs(): void
  85. {
  86. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  87. $ip = IpAddress::fromString('192.0.2.99');
  88. /** @var int $catId */
  89. $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='spam'");
  90. // ip_scores row with a score below threshold and an ancient
  91. // last_report_at — meets the drop rule.
  92. $this->db->insert('ip_scores', [
  93. 'ip_bin' => $ip->binary(),
  94. 'ip_text' => $ip->text(),
  95. 'category_id' => $catId,
  96. 'score' => '0.0001',
  97. 'report_count_30d' => 0,
  98. 'last_report_at' => $clock->now()->modify('-200 days')->format('Y-m-d H:i:s'),
  99. 'recomputed_at' => $clock->now()->modify('-200 days')->format('Y-m-d H:i:s'),
  100. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  101. // No reports → recompute will set last_report_at to (now - 366d)
  102. // → drop rule fires.
  103. $job = $this->buildJob($clock);
  104. $logger = new Logger('test');
  105. $logger->pushHandler(new NullHandler());
  106. $job->run(new JobContext($clock, $logger, ['full' => true]));
  107. $remaining = (int) $this->db->fetchOne(
  108. 'SELECT COUNT(*) FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
  109. ['bin' => $ip->binary(), 'cat' => $catId],
  110. ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
  111. );
  112. self::assertSame(0, $remaining);
  113. }
  114. private function insertReport(IpAddress $ip, int $catId, int $reporterId, float $weight, \DateTimeImmutable $when): void
  115. {
  116. $this->db->insert('reports', [
  117. 'ip_bin' => $ip->binary(),
  118. 'ip_text' => $ip->text(),
  119. 'category_id' => $catId,
  120. 'reporter_id' => $reporterId,
  121. 'weight_at_report' => number_format($weight, 2, '.', ''),
  122. 'metadata_json' => null,
  123. 'received_at' => $when->format('Y-m-d H:i:s'),
  124. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  125. }
  126. private function buildJob(FixedClock $clock): RecomputeScoresJob
  127. {
  128. $reports = new ReportRepository($this->db);
  129. $ipScores = new IpScoreRepository($this->db);
  130. $categories = new CategoryRepository($this->db);
  131. $scorer = new PairScorer($reports, $categories, $clock, 365);
  132. return new RecomputeScoresJob($reports, $ipScores, $scorer);
  133. }
  134. }