|
|
@@ -0,0 +1,157 @@
|
|
|
+<?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);
|
|
|
+ }
|
|
|
+}
|