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); } }