monitor/api/tests/Unit/ApplicationTest.php
fds2610-bot 79ccef099d Hooks-KPI im KPI-Monitor ergaenzen (Issue #38)
Die im Issue beschriebene Event-Anzeige (neuestes Event oben, aeltere
schieben nach, Ueberschuss wird abgeschnitten) ist bereits seit Phase 4
umgesetzt (buildEventLines()/MAX_EVENT_LINES, F5) und braucht keine
Aenderung.

Neu ist die vierte, immer sichtbare KPI-Kachel ganz rechts im
KPI-Monitor: "Hooks: n" zaehlt die seit dem letzten Start des
api-Containers empfangenen Webhooks. Der Zaehler ist dateibasiert
(Metrics\HookCounter), weil der eingebaute PHP-Server jede Anfrage als
eigene Skriptausfuehrung behandelt; docker/entrypoint.sh setzt die Datei
beim Start des api-Containers (nicht des sync-Containers) zurueck. Neuer
oeffentlicher Endpunkt GET /api/v1/webhooks/stats liefert den Stand,
das Frontend haengt ihn als eigene Kachel an (buildHooksKpi()) und
reserviert ihr dafuer eine feste Kachel statt sie der MAX_KPI_TILES-
Kappung der uebrigen KPIs zu unterwerfen. Der Wert fliesst zusaetzlich
in den SSE-Fingerabdruck (ChangeFeed) ein.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:18:05 +02:00

288 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api\Tests;
use Monitor\Api\Application;
use Monitor\Api\Config;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use PHPUnit\Framework\TestCase;
final class ApplicationTest extends TestCase
{
private Application $app;
protected function setUp(): void
{
$config = new Config(['MONITOR_ADMIN_API_KEY' => 'admin-secret', 'MONITOR_STATUS_FILE' => '/does/not/exist']);
$this->app = new Application(Database::inMemory(), $config);
}
public function testUnknownRouteReturns404(): void
{
$response = $this->app->handle(Request::create('GET', '/nope'));
self::assertSame(404, $response->status);
}
public function testWrongMethodReturns405(): void
{
$response = $this->app->handle(Request::create('DELETE', '/api/v1/tickets'));
self::assertSame(405, $response->status);
}
public function testWritingWithoutAgentKeyReturns401(): void
{
$response = $this->app->handle(Request::create('POST', '/api/v1/tickets', body: ['title' => 'X']));
self::assertSame(401, $response->status);
}
public function testRegisterAgentRequiresAdminKey(): void
{
$response = $this->app->handle(Request::create('POST', '/api/v1/agents', body: [
'display_name' => 'Alice', 'role' => 'entwickler',
]));
self::assertSame(401, $response->status);
}
public function testFullFlowRegisterAgentThenCreateTicket(): void
{
$registerResponse = $this->app->handle(Request::create(
'POST',
'/api/v1/agents',
body: ['display_name' => 'Alice', 'role' => 'entwickler'],
headers: ['X-Admin-Key' => 'admin-secret']
));
self::assertSame(201, $registerResponse->status);
$apiKey = $registerResponse->body()['api_key'];
$createResponse = $this->app->handle(Request::create(
'POST',
'/api/v1/tickets',
body: ['title' => 'Neues Ticket'],
headers: ['X-Api-Key' => $apiKey]
));
self::assertSame(201, $createResponse->status);
$listResponse = $this->app->handle(Request::create('GET', '/api/v1/tickets'));
self::assertCount(1, $listResponse->body()['tickets']);
}
/**
* Regression: Ein unbekanntes Ticket lief bis in die FOREIGN-KEY-Constraint und kam als
* ungefangene PDOException samt Stacktrace beim Client an.
*/
public function testActivityWithUnknownTicketReturns422WithoutInternals(): void
{
$registerResponse = $this->app->handle(Request::create(
'POST',
'/api/v1/agents',
body: ['display_name' => 'Alice', 'role' => 'entwickler'],
headers: ['X-Admin-Key' => 'admin-secret']
));
$apiKey = $registerResponse->body()['api_key'];
$agentId = $registerResponse->body()['agent']['id'];
$response = $this->app->handle(Request::create(
'POST',
'/api/v1/agents/' . $agentId . '/activity',
body: ['state' => 'working', 'current_ticket' => 999],
headers: ['X-Api-Key' => $apiKey]
));
self::assertSame(422, $response->status);
self::assertStringNotContainsString('PDOException', json_encode($response->body()));
}
public function testPatchUnknownTicketReturns404(): void
{
$registerResponse = $this->app->handle(Request::create(
'POST',
'/api/v1/agents',
body: ['display_name' => 'Alice', 'role' => 'entwickler'],
headers: ['X-Admin-Key' => 'admin-secret']
));
$apiKey = $registerResponse->body()['api_key'];
$response = $this->app->handle(Request::create(
'PATCH',
'/api/v1/tickets/999',
body: ['column' => 'bereit'],
headers: ['X-Api-Key' => $apiKey]
));
self::assertSame(404, $response->status);
}
/**
* Issue #27: die Projektliste des Codeberg-Sync ist ueber ein Modal im Frontend
* editierbar - lesend oeffentlich (fuer das Modal beim Oeffnen), schreibend hinter dem
* Admin-Key wie die Agenten-Registrierung.
*/
public function testRepoConfigsIndexIsPublic(): void
{
$response = $this->app->handle(Request::create('GET', '/api/v1/repo-configs'));
self::assertSame(200, $response->status);
self::assertIsArray($response->body()['repos']);
}
/**
* Der X-Admin-Key-Header macht aus dem fetch() im Projekte-Modal einen preflighted
* request. Wird OPTIONS nicht mit 2xx beantwortet, sendet der Browser den POST nie und
* das Modal zeigt nur "Netzwerkfehler beim Abrufen der Ressource".
*/
public function testCorsPreflightOnAdminEndpointSucceeds(): void
{
$response = $this->app->handle(Request::create('OPTIONS', '/api/v1/repo-configs'));
self::assertSame(204, $response->status);
}
public function testCreateRepoConfigRequiresAdminKey(): void
{
$response = $this->app->handle(Request::create('POST', '/api/v1/repo-configs', body: [
'owner' => 'fds2610', 'repo' => 'monitor', 'color' => '#fbca04',
]));
self::assertSame(401, $response->status);
}
public function testFullFlowCreateUpdateAndDeleteRepoConfigWithAdminKey(): void
{
// Bewusst ein Repo, das die Migration nicht schon geseedet hat (monitor/Steuerkram/
// nc-gantt, siehe 003_repo_configs.sql) - sonst scheitert schon das Anlegen an der
// Dublettenpruefung, und der eigentliche Ablauf wird gar nicht geprueft.
$createResponse = $this->app->handle(Request::create(
'POST',
'/api/v1/repo-configs',
body: ['owner' => 'fds2610', 'repo' => 'weiteres-projekt', 'color' => '#fbca04'],
headers: ['X-Admin-Key' => 'admin-secret']
));
self::assertSame(201, $createResponse->status);
$id = $createResponse->body()['repo']['id'];
$updateResponse = $this->app->handle(Request::create(
'PATCH',
'/api/v1/repo-configs/' . $id,
body: ['owner' => 'fds2610', 'repo' => 'weiteres-projekt', 'color' => '#000000'],
headers: ['X-Admin-Key' => 'admin-secret']
));
self::assertSame(200, $updateResponse->status);
self::assertSame('#000000', $updateResponse->body()['repo']['color']);
$deleteResponse = $this->app->handle(Request::create(
'DELETE',
'/api/v1/repo-configs/' . $id,
headers: ['X-Admin-Key' => 'admin-secret']
));
self::assertSame(200, $deleteResponse->status);
$listResponse = $this->app->handle(Request::create('GET', '/api/v1/repo-configs'));
self::assertSame([], array_filter(
$listResponse->body()['repos'],
static fn (array $r): bool => (int) $r['id'] === (int) $id
));
}
/**
* Der SSE-Endpoint (F13) ist wie die anderen GET-Endpunkte oeffentlich und liefert eine
* Stream-Antwort - der eigentliche Polling-Loop wird hier bewusst NICHT ausgefuehrt
* (siehe StreamControllerTest fuer die Schleifenlogik selbst), das wuerde echte sleep()-
* Aufrufe ausloesen.
*/
public function testStreamRouteIsPublicAndReturnsAStreamResponse(): void
{
$response = $this->app->handle(Request::create('GET', '/api/v1/stream'));
self::assertSame(200, $response->status);
self::assertTrue($response->isStream());
}
/**
* Der Webhook-Endpunkt ist aus dem Internet erreichbar - ohne gueltige Signatur darf er
* nichts ausloesen. Die setUp()-Config setzt kein CODEBERG_WEBHOOK_SECRET, also weist
* er hier grundsaetzlich ab (siehe WebhookAuthenticatorTest fuer die Signaturlogik).
*/
public function testCodebergWebhookWithoutSignatureReturns401(): void
{
$response = $this->app->handle(Request::create(
'POST',
'/api/v1/webhooks/codeberg',
rawBody: '{"action":"opened"}'
));
self::assertSame(401, $response->status);
}
public function testCodebergWebhookWithValidSignatureIsAccepted(): void
{
$payload = '{"action":"opened","issue":{"number":1}}';
$triggerPath = sys_get_temp_dir() . '/monitor-app-webhook-' . uniqid() . '/sync-requested';
$hooksPath = sys_get_temp_dir() . '/monitor-app-webhook-' . uniqid() . '/hooks-count';
$app = new Application(Database::inMemory(), new Config([
'MONITOR_STATUS_FILE' => '/does/not/exist',
'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret',
'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath,
'MONITOR_HOOKS_COUNTER_FILE' => $hooksPath,
]));
$response = $app->handle(Request::create(
'POST',
'/api/v1/webhooks/codeberg',
headers: ['X-Forgejo-Signature' => hash_hmac('sha256', $payload, 'webhook-secret')],
rawBody: $payload
));
self::assertSame(202, $response->status);
self::assertFileExists($triggerPath);
unlink($triggerPath);
rmdir(dirname($triggerPath));
unlink($hooksPath);
rmdir(dirname($hooksPath));
}
/**
* Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, zaehlt akzeptierte
* Webhook-Zustellungen unabhaengig vom Event-Typ.
*/
public function testWebhookStatsRouteIsPublicAndCountsAcceptedWebhooks(): void
{
$payload = '{"action":"opened","issue":{"number":1}}';
$triggerPath = sys_get_temp_dir() . '/monitor-app-webhook-stats-' . uniqid() . '/sync-requested';
$hooksPath = sys_get_temp_dir() . '/monitor-app-webhook-stats-' . uniqid() . '/hooks-count';
$app = new Application(Database::inMemory(), new Config([
'MONITOR_STATUS_FILE' => '/does/not/exist',
'CODEBERG_WEBHOOK_SECRET' => 'webhook-secret',
'MONITOR_SYNC_TRIGGER_FILE' => $triggerPath,
'MONITOR_HOOKS_COUNTER_FILE' => $hooksPath,
]));
$emptyStatsResponse = $app->handle(Request::create('GET', '/api/v1/webhooks/stats'));
self::assertSame(200, $emptyStatsResponse->status);
self::assertSame(0, $emptyStatsResponse->body()['count']);
$app->handle(Request::create(
'POST',
'/api/v1/webhooks/codeberg',
headers: ['X-Forgejo-Signature' => hash_hmac('sha256', $payload, 'webhook-secret')],
rawBody: $payload
));
$statsResponse = $app->handle(Request::create('GET', '/api/v1/webhooks/stats'));
self::assertSame(1, $statsResponse->body()['count']);
unlink($triggerPath);
rmdir(dirname($triggerPath));
unlink($hooksPath);
rmdir(dirname($hooksPath));
}
}