| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Jobs;
- use App\Application\Jobs\RecomputeScoresJob;
- use App\Domain\Ip\IpAddress;
- use App\Domain\Jobs\JobContext;
- use App\Domain\Reputation\PairScorer;
- use App\Domain\Time\FixedClock;
- use App\Infrastructure\Category\CategoryRepository;
- use App\Infrastructure\Reputation\IpScoreRepository;
- use App\Infrastructure\Reputation\ReportRepository;
- use App\Tests\Integration\Support\AppTestCase;
- use Doctrine\DBAL\ParameterType;
- use Monolog\Handler\NullHandler;
- use Monolog\Logger;
- /**
- * Bulk recompute over real reports rows. The headline scenario is the M05
- * acceptance: clock-forward 30 days between two reports with a 14-day
- * exponential half-life and verify the score matches `0.5^(30/14) +
- * 0.5^(0/14) = 1 + ~0.226 = ~1.226`.
- */
- final class RecomputeScoresJobTest extends AppTestCase
- {
- public function testFullModeAppliesDecayAcross30DayWindow(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- // 30 days ago: a report
- $reporterId = $this->createReporter('decay-test');
- $ip = IpAddress::fromString('203.0.113.42');
- /** @var int $catId */
- $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='brute_force'");
- self::assertGreaterThan(0, $catId);
- $thirtyDaysAgo = $clock->now()->modify('-30 days');
- $this->insertReport($ip, $catId, $reporterId, 1.0, $thirtyDaysAgo);
- // and a fresh report just now
- $this->insertReport($ip, $catId, $reporterId, 1.0, $clock->now());
- // Pre-existing ip_scores row so the recompute touches it. (In real
- // life the synchronous ingest path would have created this; for an
- // isolated test we simulate with a stale row.)
- $this->db->insert('ip_scores', [
- 'ip_bin' => $ip->binary(),
- 'ip_text' => $ip->text(),
- 'category_id' => $catId,
- 'score' => '0.0000',
- 'report_count_30d' => 0,
- 'last_report_at' => $thirtyDaysAgo->format('Y-m-d H:i:s'),
- 'recomputed_at' => $thirtyDaysAgo->format('Y-m-d H:i:s'),
- ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
- $job = $this->buildJob($clock);
- $logger = new Logger('test');
- $logger->pushHandler(new NullHandler());
- $result = $job->run(new JobContext($clock, $logger, ['full' => true]));
- self::assertGreaterThan(0, $result->itemsProcessed);
- $newScore = (float) $this->db->fetchOne(
- 'SELECT score FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
- ['bin' => $ip->binary(), 'cat' => $catId],
- ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
- );
- // Expected: 1.0 (fresh) + 0.5^(30/14) (30-day-old) ≈ 1.2266.
- $expected = 1.0 + 0.5 ** (30.0 / 14.0);
- self::assertEqualsWithDelta($expected, $newScore, 0.001);
- }
- public function testIncrementalModePicksUpRecentlyTouchedPairs(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $reporterId = $this->createReporter('inc-test');
- $ip = IpAddress::fromString('198.51.100.7');
- /** @var int $catId */
- $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='scanner'");
- $this->insertReport($ip, $catId, $reporterId, 1.0, $clock->now()->modify('-2 minutes'));
- $job = $this->buildJob($clock);
- $logger = new Logger('test');
- $logger->pushHandler(new NullHandler());
- $result = $job->run(new JobContext($clock, $logger, []));
- self::assertGreaterThanOrEqual(1, $result->itemsProcessed);
- $score = (float) $this->db->fetchOne(
- 'SELECT score FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
- ['bin' => $ip->binary(), 'cat' => $catId],
- ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
- );
- // Fresh report → score ~ 1.0
- self::assertEqualsWithDelta(1.0, $score, 0.01);
- }
- public function testDropsStaleNearZeroPairs(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $ip = IpAddress::fromString('192.0.2.99');
- /** @var int $catId */
- $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='spam'");
- // ip_scores row with a score below threshold and an ancient
- // last_report_at — meets the drop rule.
- $this->db->insert('ip_scores', [
- 'ip_bin' => $ip->binary(),
- 'ip_text' => $ip->text(),
- 'category_id' => $catId,
- 'score' => '0.0001',
- 'report_count_30d' => 0,
- 'last_report_at' => $clock->now()->modify('-200 days')->format('Y-m-d H:i:s'),
- 'recomputed_at' => $clock->now()->modify('-200 days')->format('Y-m-d H:i:s'),
- ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
- // No reports → recompute will set last_report_at to (now - 366d)
- // → drop rule fires.
- $job = $this->buildJob($clock);
- $logger = new Logger('test');
- $logger->pushHandler(new NullHandler());
- $job->run(new JobContext($clock, $logger, ['full' => true]));
- $remaining = (int) $this->db->fetchOne(
- 'SELECT COUNT(*) FROM ip_scores WHERE ip_bin = :bin AND category_id = :cat',
- ['bin' => $ip->binary(), 'cat' => $catId],
- ['bin' => ParameterType::LARGE_OBJECT, 'cat' => ParameterType::INTEGER],
- );
- self::assertSame(0, $remaining);
- }
- private function insertReport(IpAddress $ip, int $catId, int $reporterId, float $weight, \DateTimeImmutable $when): void
- {
- $this->db->insert('reports', [
- 'ip_bin' => $ip->binary(),
- 'ip_text' => $ip->text(),
- 'category_id' => $catId,
- 'reporter_id' => $reporterId,
- 'weight_at_report' => number_format($weight, 2, '.', ''),
- 'metadata_json' => null,
- 'received_at' => $when->format('Y-m-d H:i:s'),
- ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
- }
- private function buildJob(FixedClock $clock): RecomputeScoresJob
- {
- $reports = new ReportRepository($this->db);
- $ipScores = new IpScoreRepository($this->db);
- $categories = new CategoryRepository($this->db);
- $scorer = new PairScorer($reports, $categories, $clock, 365);
- return new RecomputeScoresJob($reports, $ipScores, $scorer);
- }
- }
|