container, 'set')) { /** @var \DI\Container $container */ $container = $this->container; $container->set( \App\Infrastructure\Http\Middleware\InternalTokenMiddleware::class, new \App\Infrastructure\Http\Middleware\InternalTokenMiddleware( new \Slim\Psr7\Factory\ResponseFactory(), self::TOKEN, ), ); } // Rebuild the app so it picks up the patched middleware. $this->app = \App\App\AppFactory::build($this->container); } public function testTickRequiresToken(): void { $resp = $this->internalRequest('POST', '/internal/jobs/tick', headers: []); self::assertSame(401, $resp->getStatusCode()); } public function testTickRejectsWrongToken(): void { $resp = $this->internalRequest( 'POST', '/internal/jobs/tick', headers: ['Authorization' => 'Bearer wrong'], ); self::assertSame(401, $resp->getStatusCode()); } public function testExternalSourceReturns404OpaquePerSpec(): void { $resp = $this->internalRequest( 'POST', '/internal/jobs/tick', headers: ['Authorization' => 'Bearer ' . self::TOKEN], remoteAddr: '8.8.8.8', ); self::assertSame(404, $resp->getStatusCode()); } public function testTickSuccessProducesEnvelope(): void { $resp = $this->internalRequest( 'POST', '/internal/jobs/tick', headers: ['Authorization' => 'Bearer ' . self::TOKEN], ); self::assertSame(200, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('tick', $body['job']); self::assertSame('success', $body['status']); self::assertArrayHasKey('run_id', $body); self::assertArrayHasKey('duration_ms', $body); } public function testRecomputeSucceedsWithEmptyTables(): void { $resp = $this->internalRequest( 'POST', '/internal/jobs/recompute-scores', headers: ['Authorization' => 'Bearer ' . self::TOKEN], ); self::assertSame(200, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('recompute-scores', $body['job']); self::assertSame('success', $body['status']); } public function testConcurrentRecomputeProducesOneSuccessOneSkipped(): void { // Pre-acquire the lock to simulate "another worker is busy". The // second HTTP call must come back 409 with skipped_locked. $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); $lockSql = 'INSERT INTO job_locks (job_name, acquired_by, acquired_at, expires_at) ' . 'VALUES (:job, :owner, :acquired, :expires)'; $this->db->executeStatement($lockSql, [ 'job' => 'recompute-scores', 'owner' => 'OTHER', 'acquired' => $now->format('Y-m-d H:i:s'), 'expires' => $now->modify('+5 minutes')->format('Y-m-d H:i:s'), ]); $resp = $this->internalRequest( 'POST', '/internal/jobs/recompute-scores', headers: ['Authorization' => 'Bearer ' . self::TOKEN], body: '{"full": true}', ); self::assertSame(409, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('skipped_locked', $body['status']); } public function testRefreshGeoipReturns412(): void { $resp = $this->internalRequest( 'POST', '/internal/jobs/refresh-geoip', headers: ['Authorization' => 'Bearer ' . self::TOKEN], ); self::assertSame(412, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('not_implemented', $body['error']); } public function testStatusListsAllRegisteredJobs(): void { $resp = $this->internalRequest( 'GET', '/internal/jobs/status', headers: ['Authorization' => 'Bearer ' . self::TOKEN], ); self::assertSame(200, $resp->getStatusCode()); $body = $this->decode($resp); self::assertArrayHasKey('jobs', $body); self::assertArrayHasKey('recompute-scores', $body['jobs']); self::assertArrayHasKey('cleanup-audit', $body['jobs']); self::assertArrayHasKey('enrich-pending', $body['jobs']); self::assertArrayHasKey('tick', $body['jobs']); } public function testCleanupAuditSucceedsAndRecordsRun(): void { $resp = $this->internalRequest( 'POST', '/internal/jobs/cleanup-audit', headers: ['Authorization' => 'Bearer ' . self::TOKEN], ); self::assertSame(200, $resp->getStatusCode()); $body = $this->decode($resp); self::assertSame('cleanup-audit', $body['job']); self::assertSame('success', $body['status']); $row = $this->db->fetchAssociative( "SELECT triggered_by, status FROM job_runs WHERE job_name = 'cleanup-audit'" ); self::assertNotFalse($row); self::assertSame('schedule', $row['triggered_by']); self::assertSame('success', $row['status']); } /** * @param array $headers */ private function internalRequest( string $method, string $path, array $headers = [], ?string $body = null, string $remoteAddr = '127.0.0.1', ): \Psr\Http\Message\ResponseInterface { $factory = new \Slim\Psr7\Factory\ServerRequestFactory(); $request = $factory->createServerRequest($method, $path, ['REMOTE_ADDR' => $remoteAddr]); foreach ($headers as $name => $value) { $request = $request->withHeader($name, $value); } if ($body !== null) { $stream = (new \Slim\Psr7\Factory\StreamFactory())->createStream($body); $request = $request->withBody($stream); $request = $request->withHeader('Content-Type', 'application/json'); } return $this->app->handle($request); } }