Phase 1 der Neuausrichtung: REST-API fuer Tickets/Events/Systeme/KPIs/Agenten

Setzt Phase 1 aus Visualization.md um: Vanilla-PHP-REST-API mit SQLite-Persistenz,
statischen API-Keys pro Agent, Codeberg-Sync fuer Backlog/Done, PHPUnit-Tests und
vollstaendiger API.md-Dokumentation - Vorgaben aus Detailantworten.md (F1-F16)
umgesetzt. Entwicklung/Build/Test laufen komplett in Docker-Compose-Containern
(PHP 8.2 + SQLite + Composer + PHPUnit/pcov), analog zum NC-Gantt-Projekt ist auf
dem Host kein PHP/Composer noetig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-07-27 19:38:52 +02:00
parent 56d7a8cfac
commit 857fc88834
60 changed files with 3488 additions and 0 deletions

10
.env.example Normal file
View file

@ -0,0 +1,10 @@
# Kopieren nach .env und Werte anpassen. Wird von docker-compose.yml gelesen.
# Administrator-Key zur Registrierung neuer Agenten (POST /api/v1/agents, Header X-Admin-Key)
MONITOR_ADMIN_API_KEY=change-me
# Codeberg-Repo, aus dem Backlog/Done per Sync-Skript befuellt werden
CODEBERG_OWNER=fds2610
CODEBERG_REPO=monitor
# Optional: Token mit Lesezugriff, falls Rate-Limits ohne Token zu knapp sind
CODEBERG_TOKEN=

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
api/vendor/
api/data/
.env

221
API.md Normal file
View file

@ -0,0 +1,221 @@
# monitor REST-API (Phase 1)
Diese API ist die zentrale Schnittstelle, ueber die entfernte Agenten (Menschen oder
automatisierte Systeme) ihre Aktivitaet melden: Tickets anlegen/verschieben, Events melden,
Systemstatus berichten, KPIs eintragen und den eigenen Zustand aktualisieren. Sie ist Teil
von Phase 1 der Neuausrichtung (siehe [Visualization.md](Visualization.md), Abschnitt 9).
Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
(`api/data/monitor.sqlite`). Details zum lokalen Setup: [api/README.md](api/README.md).
## Inhaltsverzeichnis
- [Grundlagen](#grundlagen)
- [Authentifizierung](#authentifizierung)
- [Agenten](#agenten)
- [Tickets](#tickets)
- [Events](#events)
- [Systeme](#systeme)
- [KPIs](#kpis)
- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
- [Fehlerformat](#fehlerformat)
## Grundlagen
- Basis-Pfad: `/api/v1`
- Request-/Response-Body: `application/json`
- Alle Zeitstempel: ISO-8601 UTC (`YYYY-MM-DDTHH:mm:ssZ`), serverseitig erzeugt via
`Database::now()`, sofern nicht explizit im Request angegeben.
- Lesende Endpunkte (`GET`) sind oeffentlich, da sie das Frontend bedienen.
- Schreibende Endpunkte (`POST`/`PATCH`) erfordern einen Agenten- oder Admin-Key
(siehe [Authentifizierung](#authentifizierung)).
## Authentifizierung
Zwei Schluesseltypen, beide statisch (F14):
| Header | Verwendet fuer | Herkunft |
|---|---|---|
| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` |
| `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben |
Der Klartext-API-Key eines Agenten wird **nur einmal** bei der Registrierung ausgegeben.
Gespeichert wird ausschliesslich ein SHA-256-Hash in SQLite (Tabelle `agents`,
Spalte `api_key_hash`) - der Klartext ist danach nicht mehr rekonstruierbar und muss vom
Agenten sicher verwahrt werden.
Fehlt oder passt ein Key nicht, antwortet die API mit `401 Unauthorized`.
## Agenten
Rollen (F1/F2): `entwickler`, `product_owner`, `qa`, `user`. Ein Agent bekommt bei der
Registrierung fest einen von `MONITOR_DESK_COUNT` (Standard 8) Schreibtischen zugewiesen
(niedrigster freier Index). Die Rolle `user` bekommt nie einen Schreibtisch (kein fester
Sitzplatz). Sind alle Schreibtische belegt, wird trotzdem registriert, aber ohne
Schreibtisch (`desk_index: null`).
### `POST /api/v1/agents` — Agent registrieren
Auth: `X-Admin-Key`.
```json
// Request
{ "display_name": "Alice", "role": "entwickler" }
```
```json
// Response 201
{
"agent": {
"id": 1, "display_name": "Alice", "role": "entwickler",
"desk_index": 0, "state": "idle", "current_ticket_id": null,
"created_at": "2026-07-27T10:00:00Z", "last_seen_at": "2026-07-27T10:00:00Z"
},
"api_key": "‹64 Hex-Zeichen, nur jetzt sichtbar›"
}
```
### `GET /api/v1/agents` — Alle Agenten
Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }`
### `POST /api/v1/agents/{id}/activity` — Eigenen Zustand melden
Auth: `X-Api-Key` des betroffenen Agenten (ein Agent darf nur seinen eigenen Zustand
melden, sonst `403`).
```json
// Request
{ "state": "working", "current_ticket": 42 }
```
Response 200: aktualisiertes Agent-Objekt.
## Tickets
Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`.
- `backlog` und `done` werden zusaetzlich regelmaessig aus Codeberg-Issues befuellt
(siehe [Codeberg-Sync](#codeberg-sync-backlogdone)), alle anderen Bewegungen ergeben
sich ausschliesslich aus Agenten-Meldungen (F4).
- Ticket-Typ und Farbe (statt Prioritaet) werden bei Codeberg-Tickets aus dem ersten
Issue-Label uebernommen (F4).
- Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik;
die API liefert Tickets dafuer sortiert nach Spalte und Alter.
### `GET /api/v1/tickets` — Board-Zustand
Query-Parameter: `column` (optional, filtert auf eine Spalte).
Response: `{ "tickets": [ { "id", "external_ref", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }`
### `POST /api/v1/tickets` — Ticket anlegen
Auth: `X-Api-Key`.
```json
// Request (nur "title" ist Pflicht)
{ "title": "Neues Bug-Ticket", "description": "...", "column": "backlog", "type": "bug", "color": "#ff0000" }
```
Response `201` mit dem angelegten Ticket, `422` bei fehlendem `title` oder ungueltiger
`column`.
### `PATCH /api/v1/tickets/{id}` — Ticket verschieben/aendern
Auth: `X-Api-Key`. Erlaubte Felder im Body: `column`, `title`, `description`, `type`,
`color`, `assignee_agent_id` (alle optional, nur uebergebene Felder werden geaendert).
Response `200` mit dem aktualisierten Ticket, `404` falls unbekannt, `422` bei ungueltiger
`column`.
## Events
`severity`: `warning` (gelb) oder `error` (rot). Neuestes Event zuerst (F5).
### `POST /api/v1/events` — Event melden
Auth: `X-Api-Key`.
```json
{ "system_id": "srv1", "name": "Ping", "message": "Host nicht erreichbar", "severity": "error" }
```
Pflichtfelder: `name`, `message`, `severity`. `occurred_at` optional (Default: jetzt).
Response `201` mit dem gespeicherten Event.
### `GET /api/v1/events` — Event-Liste
Query-Parameter: `limit` (Default `5`, entspricht der im Plan festgelegten Zeilenzahl des
Event-Monitors, F5; Maximum intern 200).
Response: `{ "events": [ { "id", "system_id", "name", "message", "severity", "occurred_at", "created_at" }, ... ] }`
## Systeme
Bis zu 9 Kacheln (F6, `SystemRepository::MAX_TILES`), Fehler zuerst sortiert. Aktuell
werden Systeme zusaetzlich aus der bestehenden `status.txt` (geschrieben von
`check_fds.sh`) importiert; per API gemeldete Systeme (`source: "api"`) haben Vorrang und
werden vom Legacy-Import nicht ueberschrieben.
### `POST /api/v1/systems/{id}/status` — Systemstatus melden
Auth: `X-Api-Key`.
```json
{ "status": "ok", "name": "Webserver", "type": "host", "detail": "optional" }
```
`status`: `ok` oder `error`. Response `200` mit dem gespeicherten System, `422` bei
ungueltigem `status`.
### `GET /api/v1/systems` — Systemuebersicht
Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", "last_check_at" }, ... ] }`
(maximal 9 Eintraege).
## KPIs
F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die
generische Speicher-/Abfragestruktur bereit.
### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden
Auth: `X-Api-Key`.
```json
{ "value": 12.5, "label": "Offene Tickets", "unit": "count", "timestamp": "2026-07-27T10:00:00Z" }
```
Pflichtfeld: `value` (numerisch). `label`/`unit`/`timestamp` optional. Response `201` mit
dem KPI inkl. Verlauf.
### `GET /api/v1/kpis` — Alle KPIs
Response: `{ "kpis": [ { "key", "label", "unit", "current_value", "history": [ { "value", "recorded_at" }, ... ] }, ... ] }`
## Codeberg-Sync (Backlog/Done)
`api/bin/sync-codeberg.php` (per Cron oder manuell auszufuehren) liest offene und
geschlossene Issues aus dem konfigurierten Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`)
und gleicht sie ueber `external_ref` (= Issue-Nummer) mit den Tickets ab:
- Offene Issues → Spalte `backlog` (nur falls das Ticket noch in `backlog`/`done` ist -
manuell auf dem Board bewegte Tickets werden nicht zurueckgesetzt).
- Geschlossene Issues → Spalte `done`.
- Ticket-Typ/-Farbe kommen vom ersten Issue-Label (Name/Farbe).
Aufruf via Docker: `docker compose run --rm api php bin/sync-codeberg.php`.
## Fehlerformat
Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status:
| Status | Bedeutung |
|---|---|
| `400` | Ungueltige Anfrage (z. B. unbekannter Endpunkt-Parameter) |
| `401` | Fehlender/ungueltiger `X-Api-Key`/`X-Admin-Key` |
| `403` | Aktion nicht erlaubt (z. B. fremde Agent-Aktivitaet melden) |
| `404` | Ressource nicht gefunden |
| `405` | HTTP-Methode fuer diesen Pfad nicht erlaubt |
| `422` | Validierungsfehler (Pflichtfeld fehlt, ungueltiger Enum-Wert) |

3
api/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
vendor/
data/
.git/

22
api/README.md Normal file
View file

@ -0,0 +1,22 @@
# monitor REST-API (Phase 1)
Vanilla-PHP-REST-API (kein Framework, siehe Visualization.md Abschnitt 5) fuer Tickets,
Events, Systeme, KPIs und Agenten. Persistenz in SQLite (`api/data/monitor.sqlite`).
Vollstaendige Endpunkt-Dokumentation: [../API.md](../API.md).
## Entwicklung nur mit Docker (kein lokales PHP/Composer noetig)
Aus dem Repo-Wurzelverzeichnis:
```bash
cp .env.example .env # einmalig, MONITOR_ADMIN_API_KEY anpassen
docker compose up --build # startet die API auf http://localhost:8080
docker compose run --rm api composer test # PHPUnit inkl. Coverage
docker compose run --rm api php bin/sync-codeberg.php # Backlog/Done aus Codeberg ziehen
```
Composer-Abhaengigkeiten (nur PHPUnit als Dev-Dependency) werden beim ersten Start
automatisch **im Container** installiert (`docker/entrypoint.sh`) - auf dem Host ist
weder PHP noch Composer noch SQLite erforderlich.

26
api/bin/sync-codeberg.php Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use Monitor\Api\Config;
use Monitor\Api\Db\Database;
use Monitor\Api\Repository\TicketRepository;
use Monitor\Api\Sync\CodebergSyncService;
use Monitor\Api\Sync\CurlCodebergClient;
require dirname(__DIR__) . '/vendor/autoload.php';
$config = Config::fromEnvironment();
$db = Database::forFile($config->dbPath());
$service = new CodebergSyncService(
new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()),
new TicketRepository($db),
$config->codebergOwner(),
$config->codebergRepo(),
);
$service->sync();
fwrite(STDOUT, "Codeberg-Sync abgeschlossen.\n");

30
api/composer.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "fds2610/monitor-api",
"description": "REST-API fuer die Buero-Visualisierung von monitor (Tickets, Events, Systeme, KPIs, Agenten)",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.1",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"ext-json": "*",
"ext-curl": "*"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"autoload": {
"psr-4": {
"Monitor\\Api\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Monitor\\Api\\Tests\\": "tests/Unit/"
}
},
"scripts": {
"test": "phpunit --coverage-text",
"serve": "php -S 0.0.0.0:8080 -t public"
}
}

18
api/docker/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM php:8.2-cli-alpine
RUN apk add --no-cache curl-dev sqlite-dev \
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
&& docker-php-ext-install pdo pdo_sqlite curl \
&& pecl install pcov \
&& docker-php-ext-enable pcov \
&& apk del .build-deps
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public"]

10
api/docker/entrypoint.sh Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
# Installiert Abhaengigkeiten automatisch beim ersten Start im Container, damit auf dem
# Host ausser Docker nichts installiert werden muss (composer bleibt im Container).
set -e
if [ ! -d /app/vendor ]; then
composer install --no-interaction --no-progress --working-dir=/app
fi
exec "$@"

16
api/phpunit.xml Normal file
View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

17
api/public/index.php Normal file
View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use Monitor\Api\Application;
use Monitor\Api\Config;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
require dirname(__DIR__) . '/vendor/autoload.php';
$config = Config::fromEnvironment();
$db = Database::forFile($config->dbPath());
$app = new Application($db, $config);
$response = $app->handle(Request::fromGlobals());
$response->send();

113
api/src/Application.php Normal file
View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Monitor\Api;
use InvalidArgumentException;
use Monitor\Api\Auth\AdminAuthenticator;
use Monitor\Api\Auth\AgentAuthenticator;
use Monitor\Api\Controller\AgentController;
use Monitor\Api\Controller\EventController;
use Monitor\Api\Controller\KpiController;
use Monitor\Api\Controller\SystemController;
use Monitor\Api\Controller\TicketController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\MethodNotAllowedException;
use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Http\Router;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\EventRepository;
use Monitor\Api\Repository\KpiRepository;
use Monitor\Api\Repository\LegacyStatusFileReader;
use Monitor\Api\Repository\SystemRepository;
use Monitor\Api\Repository\TicketRepository;
/**
* Verdrahtet Router, Controller und Auth fuer alle /api/v1/*-Endpunkte aus
* Visualization.md Abschnitt 6.1. Bewusst ohne Framework/DI-Container - eine handvoll
* Objekte reichen fuer den Umfang von Phase 1.
*/
final class Application
{
private readonly Router $router;
private readonly AgentAuthenticator $agentAuth;
private readonly AdminAuthenticator $adminAuth;
public function __construct(private readonly Database $db, private readonly Config $config)
{
$agents = new AgentRepository($db, $config->deskCount());
$tickets = new TicketRepository($db);
$events = new EventRepository($db);
$systems = new SystemRepository($db);
$kpis = new KpiRepository($db);
$this->agentAuth = new AgentAuthenticator($agents);
$this->adminAuth = new AdminAuthenticator($config->adminApiKey());
$agentController = new AgentController($agents);
$ticketController = new TicketController($tickets);
$eventController = new EventController($events);
$systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
$kpiController = new KpiController($kpis);
$this->router = new Router();
$this->router->add('GET', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->index($r));
$this->router->add('POST', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->create($r), 'agent');
$this->router->add('PATCH', '/api/v1/tickets/{id}', fn (Request $r, array $p) => $ticketController->update($r, $p), 'agent');
$this->router->add('GET', '/api/v1/events', fn (Request $r, array $p) => $eventController->index($r));
$this->router->add('POST', '/api/v1/events', fn (Request $r, array $p) => $eventController->create($r), 'agent');
$this->router->add('GET', '/api/v1/systems', fn (Request $r, array $p) => $systemController->index($r));
$this->router->add('POST', '/api/v1/systems/{id}/status', fn (Request $r, array $p) => $systemController->reportStatus($r, $p), 'agent');
$this->router->add('GET', '/api/v1/kpis', fn (Request $r, array $p) => $kpiController->index($r));
$this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent');
$this->router->add('GET', '/api/v1/agents', fn (Request $r, array $p) => $agentController->index($r));
$this->router->add('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin');
$this->router->add(
'POST',
'/api/v1/agents/{id}/activity',
fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent),
'agent'
);
}
public function handle(Request $request): Response
{
try {
$match = $this->router->match($request);
} catch (MethodNotAllowedException) {
return Response::error('Methode nicht erlaubt.', 405);
}
if ($match === null) {
return Response::error('Nicht gefunden.', 404);
}
$agent = null;
if ($match->auth === 'agent') {
$agent = $this->agentAuth->authenticate($request);
if ($agent === null) {
return Response::error('Ungueltiger oder fehlender X-Api-Key-Header.', 401);
}
} elseif ($match->auth === 'admin') {
if (!$this->adminAuth->authenticate($request)) {
return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401);
}
}
try {
return ($match->handler)($request, $match->params, $agent);
} catch (NotFoundException $e) {
return Response::error($e->getMessage(), 404);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Auth;
use Monitor\Api\Http\Request;
/**
* Schuetzt die Agenten-Registrierung (POST /api/v1/agents) mit einem einzelnen
* Administrator-Key aus der Umgebungsvariable MONITOR_ADMIN_API_KEY, damit nicht jeder
* beliebig neue Agenten (und damit API-Keys) anlegen kann.
*/
final class AdminAuthenticator
{
public function __construct(private readonly ?string $adminApiKey)
{
}
public function authenticate(Request $request): bool
{
if ($this->adminApiKey === null || $this->adminApiKey === '') {
return false;
}
$provided = $request->header('x-admin-key');
return $provided !== null && hash_equals($this->adminApiKey, $provided);
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Auth;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\AgentRepository;
/**
* Prueft den "X-Api-Key"-Header gegen die registrierten Agenten (F14). Verhindert
* Fremdzugriffe/Fake-Meldungen auf schreibende Endpunkte (Abschnitt 6.3 in Visualization.md).
*/
final class AgentAuthenticator
{
public function __construct(private readonly AgentRepository $agents)
{
}
/** @return array<string, mixed>|null Der authentifizierte Agent, oder null falls ungueltig. */
public function authenticate(Request $request): ?array
{
$apiKey = $request->header('x-api-key');
if ($apiKey === null || $apiKey === '') {
return null;
}
return $this->agents->findByApiKey($apiKey);
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Auth;
/**
* Erzeugt statische API-Keys pro Agent (F14: "statische API Keys pro Agent, Keys werden in
* SQLite gespeichert"). Gespeichert wird nur der Hash - der Klartext-Key wird einmalig bei der
* Registrierung zurueckgegeben und muss vom Agenten sicher verwahrt werden.
*/
final class ApiKeyGenerator
{
public static function generate(): string
{
return bin2hex(random_bytes(32));
}
public static function hash(string $apiKey): string
{
return hash('sha256', $apiKey);
}
}

71
api/src/Config.php Normal file
View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Monitor\Api;
/**
* Liest die Konfiguration ausschliesslich aus Umgebungsvariablen (12-factor-Stil),
* damit die Docker-Compose-Umgebung ohne zusaetzliche Config-Dateien auskommt.
*/
final class Config
{
public function __construct(private readonly array $env = [])
{
}
public static function fromEnvironment(): self
{
return new self($_ENV + $_SERVER + (getenv() ?: []));
}
private function get(string $key, ?string $default = null): ?string
{
$value = $this->env[$key] ?? getenv($key);
if ($value === false || $value === null || $value === '') {
return $default;
}
return (string) $value;
}
public function dbPath(): string
{
return $this->get('MONITOR_DB_PATH', dirname(__DIR__) . '/data/monitor.sqlite');
}
public function adminApiKey(): ?string
{
return $this->get('MONITOR_ADMIN_API_KEY');
}
public function statusFilePath(): string
{
return $this->get('MONITOR_STATUS_FILE', dirname(__DIR__, 2) . '/status.txt');
}
public function codebergApiBase(): string
{
return rtrim((string) $this->get('CODEBERG_API_BASE', 'https://codeberg.org/api/v1'), '/');
}
public function codebergOwner(): string
{
return (string) $this->get('CODEBERG_OWNER', 'fds2610');
}
public function codebergRepo(): string
{
return (string) $this->get('CODEBERG_REPO', 'monitor');
}
public function codebergToken(): ?string
{
return $this->get('CODEBERG_TOKEN');
}
public function deskCount(): int
{
return (int) $this->get('MONITOR_DESK_COUNT', '8');
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use InvalidArgumentException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\AgentRepository;
final class AgentController
{
public function __construct(private readonly AgentRepository $agents)
{
}
public function index(Request $request): Response
{
return Response::json(['agents' => $this->agents->all()]);
}
public function register(Request $request): Response
{
$displayName = trim((string) ($request->body['display_name'] ?? ''));
$role = (string) ($request->body['role'] ?? '');
if ($displayName === '') {
return Response::error('Feld "display_name" ist erforderlich.', 422);
}
try {
$result = $this->agents->register($displayName, $role);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
// Der Klartext-API-Key wird nur bei der Registrierung einmalig zurueckgegeben.
return Response::json(['agent' => $result['agent'], 'api_key' => $result['api_key']], 201);
}
/**
* @param array<string, string> $params
* @param array<string, mixed> $authenticatedAgent
*/
public function activity(Request $request, array $params, array $authenticatedAgent): Response
{
if ((int) $params['id'] !== (int) $authenticatedAgent['id']) {
return Response::error('Ein Agent darf nur seine eigene Aktivitaet melden.', 403);
}
$state = (string) ($request->body['state'] ?? '');
if ($state === '') {
return Response::error('Feld "state" ist erforderlich.', 422);
}
$currentTicketId = isset($request->body['current_ticket']) ? (int) $request->body['current_ticket'] : null;
$agent = $this->agents->updateActivity((int) $params['id'], $state, $currentTicketId);
return Response::json(['agent' => $agent]);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use InvalidArgumentException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\EventRepository;
final class EventController
{
public function __construct(private readonly EventRepository $events)
{
}
public function index(Request $request): Response
{
$limit = isset($request->query['limit']) ? (int) $request->query['limit'] : EventRepository::DEFAULT_LIMIT;
return Response::json(['events' => $this->events->latest($limit)]);
}
public function create(Request $request): Response
{
foreach (['name', 'message', 'severity'] as $field) {
if (!isset($request->body[$field]) || trim((string) $request->body[$field]) === '') {
return Response::error(sprintf('Feld "%s" ist erforderlich.', $field), 422);
}
}
try {
$event = $this->events->create($request->body);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
return Response::json(['event' => $event], 201);
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\KpiRepository;
final class KpiController
{
public function __construct(private readonly KpiRepository $kpis)
{
}
public function index(Request $request): Response
{
return Response::json(['kpis' => $this->kpis->all()]);
}
/** @param array<string, string> $params */
public function report(Request $request, array $params): Response
{
if (!isset($request->body['value']) || !is_numeric($request->body['value'])) {
return Response::error('Feld "value" ist erforderlich und muss numerisch sein.', 422);
}
$kpi = $this->kpis->report(
$params['key'],
(float) $request->body['value'],
isset($request->body['label']) ? (string) $request->body['label'] : null,
isset($request->body['unit']) ? (string) $request->body['unit'] : null,
isset($request->body['timestamp']) ? (string) $request->body['timestamp'] : null,
);
return Response::json(['kpi' => $kpi], 201);
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use InvalidArgumentException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\LegacyStatusFileReader;
use Monitor\Api\Repository\SystemRepository;
final class SystemController
{
public function __construct(
private readonly SystemRepository $systems,
private readonly LegacyStatusFileReader $legacyReader,
private readonly string $statusFilePath,
) {
}
public function index(Request $request): Response
{
$this->systems->importLegacyRows($this->legacyReader->read($this->statusFilePath));
return Response::json(['systems' => $this->systems->all()]);
}
/** @param array<string, string> $params */
public function reportStatus(Request $request, array $params): Response
{
try {
$system = $this->systems->reportStatus($params['id'], $request->body);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
return Response::json(['system' => $system]);
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use InvalidArgumentException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\TicketRepository;
final class TicketController
{
public function __construct(private readonly TicketRepository $tickets)
{
}
public function index(Request $request): Response
{
$column = $request->query['column'] ?? null;
try {
return Response::json(['tickets' => $this->tickets->all($column !== null ? (string) $column : null)]);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 400);
}
}
public function create(Request $request): Response
{
if (!isset($request->body['title']) || trim((string) $request->body['title']) === '') {
return Response::error('Feld "title" ist erforderlich.', 422);
}
try {
$ticket = $this->tickets->create($request->body);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
return Response::json(['ticket' => $ticket], 201);
}
/** @param array<string, string> $params */
public function update(Request $request, array $params): Response
{
try {
$ticket = $this->tickets->update((int) $params['id'], $request->body);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
return Response::json(['ticket' => $ticket]);
}
}

66
api/src/Db/Database.php Normal file
View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Db;
use PDO;
/**
* Duenner PDO/SQLite-Wrapper. Kein ORM, keine externe Bibliothek - passend zur
* Vorgabe "kein Framework, keine externen Laufzeit-Bibliotheken" aus Visualization.md.
*/
final class Database
{
private PDO $pdo;
public function __construct(string $dsn)
{
$this->pdo = new PDO($dsn);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
public static function forFile(string $path): self
{
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$db = new self('sqlite:' . $path);
$db->migrate();
return $db;
}
public static function inMemory(): self
{
$db = new self('sqlite::memory:');
$db->migrate();
return $db;
}
public function migrate(): void
{
$migrationsDir = __DIR__ . '/migrations';
$files = glob($migrationsDir . '/*.sql') ?: [];
sort($files);
foreach ($files as $file) {
$this->pdo->exec((string) file_get_contents($file));
}
}
public function pdo(): PDO
{
return $this->pdo;
}
public function now(): string
{
return gmdate('Y-m-d\TH:i:s\Z');
}
}

View file

@ -0,0 +1,74 @@
-- Initiales Schema fuer die monitor-REST-API (Phase 1).
-- Enum-artige Spalten werden per CHECK-Constraint abgesichert, da SQLite keine echten
-- ENUM-Typen kennt. Alle Zeitangaben werden als ISO-8601-UTC-Strings gespeichert.
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('entwickler', 'product_owner', 'qa', 'user')),
api_key_hash TEXT NOT NULL UNIQUE,
desk_index INTEGER,
state TEXT NOT NULL DEFAULT 'idle',
current_ticket_id INTEGER,
created_at TEXT NOT NULL,
last_seen_at TEXT,
FOREIGN KEY (current_ticket_id) REFERENCES tickets (id)
);
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_ref TEXT,
title TEXT NOT NULL,
description TEXT,
column_name TEXT NOT NULL
CHECK (column_name IN ('backlog', 'bereit', 'in_arbeit', 'qa', 'testing', 'fertig', 'done')),
type TEXT,
color TEXT,
assignee_agent_id INTEGER,
source TEXT NOT NULL DEFAULT 'api' CHECK (source IN ('api', 'codeberg')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (assignee_agent_id) REFERENCES agents (id)
);
CREATE INDEX IF NOT EXISTS idx_tickets_column ON tickets (column_name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_external_ref ON tickets (external_ref)
WHERE external_ref IS NOT NULL;
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
system_id TEXT,
name TEXT NOT NULL,
message TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
occurred_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events (occurred_at DESC);
CREATE TABLE IF NOT EXISTS systems (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'host',
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
detail TEXT,
source TEXT NOT NULL CHECK (source IN ('legacy', 'api')),
last_check_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS kpis (
key TEXT PRIMARY KEY,
label TEXT,
unit TEXT
);
CREATE TABLE IF NOT EXISTS kpi_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kpi_key TEXT NOT NULL,
value REAL NOT NULL,
recorded_at TEXT NOT NULL,
FOREIGN KEY (kpi_key) REFERENCES kpis (key)
);
CREATE INDEX IF NOT EXISTS idx_kpi_values_key ON kpi_values (kpi_key, recorded_at DESC);

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Http;
final class MethodNotAllowedException extends \RuntimeException
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Http;
final class NotFoundException extends \RuntimeException
{
}

68
api/src/Http/Request.php Normal file
View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Http;
final class Request
{
/** @param array<string, string> $headers */
private function __construct(
public readonly string $method,
public readonly string $path,
public readonly array $query,
public readonly array $body,
private readonly array $headers,
) {
}
public static function fromGlobals(): self
{
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = rtrim((string) parse_url($uri, PHP_URL_PATH), '/');
if ($path === '') {
$path = '/';
}
parse_str((string) (parse_url($uri, PHP_URL_QUERY) ?? ''), $query);
$rawBody = file_get_contents('php://input') ?: '';
$body = [];
if ($rawBody !== '') {
$decoded = json_decode($rawBody, true);
if (is_array($decoded)) {
$body = $decoded;
}
}
$headers = [];
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace('_', '-', strtolower(substr($key, 5)));
$headers[$name] = (string) $value;
}
}
if (isset($_SERVER['CONTENT_TYPE'])) {
$headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
}
return new self($method, $path, $query, $body, $headers);
}
/** @param array<string, mixed> $body */
public static function create(string $method, string $path, array $query = [], array $body = [], array $headers = []): self
{
$normalized = [];
foreach ($headers as $name => $value) {
$normalized[strtolower($name)] = $value;
}
return new self($method, rtrim($path, '/') ?: '/', $query, $body, $normalized);
}
public function header(string $name): ?string
{
return $this->headers[strtolower($name)] ?? null;
}
}

38
api/src/Http/Response.php Normal file
View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Http;
final class Response
{
/** @param mixed $data */
private function __construct(
public readonly int $status,
private readonly mixed $data,
) {
}
/** @param mixed $data */
public static function json(mixed $data, int $status = 200): self
{
return new self($status, $data);
}
public static function error(string $message, int $status): self
{
return new self($status, ['error' => $message]);
}
public function send(): void
{
http_response_code($this->status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
public function body(): mixed
{
return $this->data;
}
}

73
api/src/Http/Router.php Normal file
View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Http;
final class RouteMatch
{
/** @param array<string, string> $params */
public function __construct(
public readonly \Closure $handler,
public readonly array $params,
public readonly ?string $auth,
) {
}
}
/**
* Minimalistischer Pfad-Router (kein Framework). Unterstuetzt Platzhalter der Form
* "{id}" innerhalb eines Segments.
*/
final class Router
{
/** @var array<int, array{method: string, pattern: string, regex: string, params: string[], handler: \Closure, auth: ?string}> */
private array $routes = [];
public function add(string $method, string $pattern, \Closure $handler, ?string $auth = null): void
{
$paramNames = [];
$regex = preg_replace_callback('/\{(\w+)\}/', function (array $m) use (&$paramNames): string {
$paramNames[] = $m[1];
return '(?P<' . $m[1] . '>[^/]+)';
}, $pattern);
$this->routes[] = [
'method' => strtoupper($method),
'pattern' => $pattern,
'regex' => '#^' . $regex . '$#',
'params' => $paramNames,
'handler' => $handler,
'auth' => $auth,
];
}
public function match(Request $request): ?RouteMatch
{
$methodMatchedElsewhere = false;
foreach ($this->routes as $route) {
if (!preg_match($route['regex'], $request->path, $matches)) {
continue;
}
if ($route['method'] !== $request->method) {
$methodMatchedElsewhere = true;
continue;
}
$params = [];
foreach ($route['params'] as $name) {
$params[$name] = $matches[$name];
}
return new RouteMatch($route['handler'], $params, $route['auth']);
}
if ($methodMatchedElsewhere) {
throw new MethodNotAllowedException();
}
return null;
}
}

View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Auth\ApiKeyGenerator;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
final class AgentRepository
{
/** @var string[] */
public const ROLES = ['entwickler', 'product_owner', 'qa', 'user'];
public function __construct(private readonly Database $db, private readonly int $deskCount = 8)
{
}
/**
* Registriert einen neuen Agenten und weist ihm bei der Registrierung fest einen
* Schreibtisch zu (F1/F2: "Agenten erhalten bei der Registrierung eine feste Zuordnung").
* Die Rolle "user" bekommt nie einen Schreibtisch (kein fester Sitzplatz, F2). Sind alle
* Schreibtische belegt, registriert sich der Agent trotzdem, aber ohne Schreibtisch
* ("kommen durch die Tuer, machen ihre Aufgaben und gehen wieder").
*
* @return array{agent: array<string, mixed>, api_key: string}
*/
public function register(string $displayName, string $role): array
{
if (!in_array($role, self::ROLES, true)) {
throw new InvalidArgumentException('Unbekannte Rolle: ' . $role);
}
$deskIndex = $role === 'user' ? null : $this->findFreeDesk();
$apiKey = ApiKeyGenerator::generate();
$now = $this->db->now();
$stmt = $this->db->pdo()->prepare(
'INSERT INTO agents (display_name, role, api_key_hash, desk_index, state, created_at, last_seen_at)
VALUES (:display_name, :role, :api_key_hash, :desk_index, :state, :created_at, :created_at)'
);
$stmt->execute([
'display_name' => $displayName,
'role' => $role,
'api_key_hash' => ApiKeyGenerator::hash($apiKey),
'desk_index' => $deskIndex,
'state' => 'idle',
'created_at' => $now,
]);
$agent = $this->findById((int) $this->db->pdo()->lastInsertId());
return ['agent' => $agent, 'api_key' => $apiKey];
}
private function findFreeDesk(): ?int
{
$stmt = $this->db->pdo()->query(
'SELECT desk_index FROM agents WHERE desk_index IS NOT NULL'
);
$occupied = array_map('intval', array_column($stmt->fetchAll(), 'desk_index'));
for ($i = 0; $i < $this->deskCount; $i++) {
if (!in_array($i, $occupied, true)) {
return $i;
}
}
return null;
}
/** @return array<string, mixed>|null */
public function findById(int $id): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM agents WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $this->hidingKey($row);
}
/** @return array<string, mixed>|null */
public function findByApiKey(string $apiKey): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM agents WHERE api_key_hash = :hash');
$stmt->execute(['hash' => ApiKeyGenerator::hash($apiKey)]);
$row = $stmt->fetch();
return $row === false ? null : $this->hidingKey($row);
}
/** @return array<int, array<string, mixed>> */
public function all(): array
{
$stmt = $this->db->pdo()->query('SELECT * FROM agents ORDER BY id ASC');
return array_map(fn (array $row) => $this->hidingKey($row), $stmt->fetchAll());
}
/** @return array<string, mixed> */
public function updateActivity(int $id, string $state, ?int $currentTicketId): array
{
$stmt = $this->db->pdo()->prepare(
'UPDATE agents SET state = :state, current_ticket_id = :ticket_id, last_seen_at = :now WHERE id = :id'
);
$stmt->execute([
'state' => $state,
'ticket_id' => $currentTicketId,
'now' => $this->db->now(),
'id' => $id,
]);
$agent = $this->findById($id);
if ($agent === null) {
throw new NotFoundException('Agent nicht gefunden: ' . $id);
}
return $agent;
}
/** @param array<string, mixed> $row @return array<string, mixed> */
private function hidingKey(array $row): array
{
unset($row['api_key_hash']);
return $row;
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
final class EventRepository
{
/** @var string[] */
public const SEVERITIES = ['warning', 'error'];
public const DEFAULT_LIMIT = 5;
public function __construct(private readonly Database $db)
{
}
/** @param array<string, mixed> $data @return array<string, mixed> */
public function create(array $data): array
{
$severity = $data['severity'] ?? null;
if (!in_array($severity, self::SEVERITIES, true)) {
throw new InvalidArgumentException('Ungueltige severity: ' . var_export($severity, true));
}
$now = $this->db->now();
$stmt = $this->db->pdo()->prepare(
'INSERT INTO events (system_id, name, message, severity, occurred_at, created_at)
VALUES (:system_id, :name, :message, :severity, :occurred_at, :created_at)'
);
$stmt->execute([
'system_id' => $data['system_id'] ?? null,
'name' => $data['name'],
'message' => $data['message'],
'severity' => $severity,
'occurred_at' => $data['occurred_at'] ?? $now,
'created_at' => $now,
]);
return $this->find((int) $this->db->pdo()->lastInsertId());
}
/** @return array<string, mixed>|null */
public function find(int $id): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM events WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/**
* Neuestes Event zuerst (F5: "Der neueste Event erscheint oben"). Default-Limit 5
* entspricht der im Plan festgelegten maximalen Zeilenzahl des Event-Monitors.
*
* @return array<int, array<string, mixed>>
*/
public function latest(int $limit = self::DEFAULT_LIMIT): array
{
$limit = max(1, min($limit, 200));
$stmt = $this->db->pdo()->prepare(
'SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT :limit'
);
$stmt->bindValue('limit', $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use Monitor\Api\Db\Database;
/**
* KPI-Monitor: konkrete Kennzahlen sind laut F7 noch offen ("alles offen derzeit, kommt
* spaeter"). Phase 1 stellt daher nur die generische Speicher-/Abfrage-Struktur bereit, wie in
* Visualization.md Abschnitt 6.1 vorgesehen.
*/
final class KpiRepository
{
public function __construct(private readonly Database $db, private readonly int $historyLimit = 20)
{
}
/** @return array<string, mixed> */
public function report(string $key, float $value, ?string $label, ?string $unit, ?string $timestamp): array
{
$stmt = $this->db->pdo()->prepare(
'INSERT INTO kpis (key, label, unit) VALUES (:key, :label, :unit)
ON CONFLICT(key) DO UPDATE SET
label = COALESCE(excluded.label, kpis.label),
unit = COALESCE(excluded.unit, kpis.unit)'
);
$stmt->execute(['key' => $key, 'label' => $label, 'unit' => $unit]);
$stmt = $this->db->pdo()->prepare(
'INSERT INTO kpi_values (kpi_key, value, recorded_at) VALUES (:key, :value, :recorded_at)'
);
$stmt->execute([
'key' => $key,
'value' => $value,
'recorded_at' => $timestamp ?? $this->db->now(),
]);
return $this->find($key);
}
/** @return array<string, mixed>|null */
public function find(string $key): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM kpis WHERE key = :key');
$stmt->execute(['key' => $key]);
$kpi = $stmt->fetch();
if ($kpi === false) {
return null;
}
$kpi['history'] = $this->history($key);
$kpi['current_value'] = $kpi['history'][0]['value'] ?? null;
return $kpi;
}
/** @return array<int, array<string, mixed>> */
private function history(string $key): array
{
$stmt = $this->db->pdo()->prepare(
'SELECT value, recorded_at FROM kpi_values WHERE kpi_key = :key ORDER BY recorded_at DESC, id DESC LIMIT :limit'
);
$stmt->bindValue('key', $key);
$stmt->bindValue('limit', $this->historyLimit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/** @return array<int, array<string, mixed>> */
public function all(): array
{
$stmt = $this->db->pdo()->query('SELECT key FROM kpis ORDER BY key ASC');
$keys = array_column($stmt->fetchAll(), 'key');
return array_map(fn (string $key) => $this->find($key), $keys);
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
/**
* Liest das bestehende Format von status.txt, das check_fds.sh schreibt:
* "<hostname> <ip> <online|offline> <YYYY-MM-DD HH:MM>" - eine Zeile pro Host.
* Bleibt bestehen, bis Agenten den Systemstatus vollstaendig per REST melden (F6).
*/
final class LegacyStatusFileReader
{
/** @return array<int, array{id: string, name: string, status: string, detail: string, last_check_at: string}> */
public function read(string $path): array
{
if (!is_file($path) || !is_readable($path)) {
return [];
}
$rows = [];
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$parts = preg_split('/\s+/', trim($line));
if ($parts === false || count($parts) < 4) {
continue;
}
[$hostname, $ip, $status] = $parts;
$timestamp = implode(' ', array_slice($parts, 3));
$rows[] = [
'id' => $hostname,
'name' => $hostname,
'status' => $status === 'online' ? 'ok' : 'error',
'detail' => $ip,
'last_check_at' => $timestamp,
];
}
return $rows;
}
}

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
final class SystemRepository
{
/** @var string[] */
public const STATUSES = ['ok', 'error'];
public const MAX_TILES = 9;
public function __construct(private readonly Database $db)
{
}
/** @param array<string, mixed> $data @return array<string, mixed> */
public function reportStatus(string $id, array $data): array
{
$status = $data['status'] ?? null;
if (!in_array($status, self::STATUSES, true)) {
throw new InvalidArgumentException('Ungueltiger status: ' . var_export($status, true));
}
$stmt = $this->db->pdo()->prepare(
'INSERT INTO systems (id, name, type, status, detail, source, last_check_at)
VALUES (:id, :name, :type, :status, :detail, :source, :last_check_at)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, type = excluded.type, status = excluded.status,
detail = excluded.detail, source = excluded.source, last_check_at = excluded.last_check_at'
);
$stmt->execute([
'id' => $id,
'name' => $data['name'] ?? $id,
'type' => $data['type'] ?? 'host',
'status' => $status,
'detail' => $data['detail'] ?? null,
'source' => 'api',
'last_check_at' => $this->db->now(),
]);
return $this->find($id);
}
/**
* Importiert Zeilen aus der bestehenden status.txt (F6). API-seitig gemeldete Systeme
* ("source = api") werden dabei nicht ueberschrieben, da sie aktueller/verbindlicher sind.
*
* @param array<int, array{id: string, name: string, status: string, detail: string, last_check_at: string}> $rows
*/
public function importLegacyRows(array $rows): void
{
$stmt = $this->db->pdo()->prepare(
'INSERT INTO systems (id, name, type, status, detail, source, last_check_at)
VALUES (:id, :name, :type, :status, :detail, :source, :last_check_at)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, status = excluded.status,
detail = excluded.detail, last_check_at = excluded.last_check_at
WHERE systems.source = :source'
);
foreach ($rows as $row) {
$stmt->execute([
'id' => $row['id'],
'name' => $row['name'],
'type' => 'host',
'status' => $row['status'],
'detail' => $row['detail'],
'source' => 'legacy',
'last_check_at' => $row['last_check_at'],
]);
}
}
/** @return array<string, mixed>|null */
public function find(string $id): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM systems WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/**
* Fehler zuerst, danach nach letztem Check sortiert, gedeckelt auf MAX_TILES (F6:
* "es werden maximal 9 Kacheln dargestellt").
*
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
$stmt = $this->db->pdo()->prepare(
"SELECT * FROM systems ORDER BY (status = 'error') DESC, last_check_at DESC LIMIT :limit"
);
$stmt->bindValue('limit', self::MAX_TILES, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
}

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
final class TicketRepository
{
/** @var string[] */
public const COLUMNS = ['backlog', 'bereit', 'in_arbeit', 'qa', 'testing', 'fertig', 'done'];
public function __construct(private readonly Database $db)
{
}
/** @param array<string, mixed> $data @return array<string, mixed> */
public function create(array $data): array
{
$column = $data['column'] ?? 'backlog';
$this->assertValidColumn($column);
$now = $this->db->now();
$stmt = $this->db->pdo()->prepare(
'INSERT INTO tickets (external_ref, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at)
VALUES (:external_ref, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)'
);
$stmt->execute([
'external_ref' => $data['external_ref'] ?? null,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'column_name' => $column,
'type' => $data['type'] ?? null,
'color' => $data['color'] ?? null,
'assignee_agent_id' => $data['assignee_agent_id'] ?? null,
'source' => $data['source'] ?? 'api',
'created_at' => $now,
'updated_at' => $now,
]);
return $this->find((int) $this->db->pdo()->lastInsertId());
}
/** @param array<string, mixed> $changes @return array<string, mixed> */
public function update(int $id, array $changes): array
{
if ($this->find($id) === null) {
throw new NotFoundException('Ticket nicht gefunden: ' . $id);
}
$fields = [];
$params = ['id' => $id];
if (array_key_exists('column', $changes)) {
$this->assertValidColumn($changes['column']);
$fields[] = 'column_name = :column_name';
$params['column_name'] = $changes['column'];
}
foreach (['title', 'description', 'type', 'color'] as $key) {
if (array_key_exists($key, $changes)) {
$fields[] = "$key = :$key";
$params[$key] = $changes[$key];
}
}
if (array_key_exists('assignee_agent_id', $changes)) {
$fields[] = 'assignee_agent_id = :assignee_agent_id';
$params['assignee_agent_id'] = $changes['assignee_agent_id'];
}
if ($fields === []) {
return $this->find($id);
}
$fields[] = 'updated_at = :updated_at';
$params['updated_at'] = $this->db->now();
$sql = 'UPDATE tickets SET ' . implode(', ', $fields) . ' WHERE id = :id';
$this->db->pdo()->prepare($sql)->execute($params);
return $this->find($id);
}
/** @return array<string, mixed>|null */
public function find(int $id): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/** @return array<string, mixed>|null */
public function findByExternalRef(string $externalRef): ?array
{
$stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref');
$stmt->execute(['ref' => $externalRef]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/**
* Liefert Tickets sortiert nach Spalte und Alter, damit das Frontend die "aelteste 4 +
* Stapel"-Regel (F4) ohne weitere API-Unterstuetzung umsetzen kann.
*
* @return array<int, array<string, mixed>>
*/
public function all(?string $column = null): array
{
if ($column !== null) {
$this->assertValidColumn($column);
$stmt = $this->db->pdo()->prepare(
'SELECT * FROM tickets WHERE column_name = :column ORDER BY created_at ASC, id ASC'
);
$stmt->execute(['column' => $column]);
return $stmt->fetchAll();
}
$stmt = $this->db->pdo()->query('SELECT * FROM tickets ORDER BY column_name ASC, created_at ASC, id ASC');
return $stmt->fetchAll();
}
/**
* Legt ein Ticket aus einem Codeberg-Issue an oder aktualisiert es (Abgleich ueber
* external_ref). Wird vom Codeberg-Sync fuer Backlog/Done verwendet (F4).
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function upsertFromCodeberg(array $data): array
{
$existing = $this->findByExternalRef((string) $data['external_ref']);
if ($existing === null) {
return $this->create($data + ['source' => 'codeberg']);
}
// Manuell auf dem Board bewegte Tickets (z. B. in_arbeit/qa/testing) werden vom
// periodischen Codeberg-Sync nicht zurueckgesetzt - nur Backlog/Done werden gepflegt.
if (!in_array($existing['column_name'], ['backlog', 'done'], true)) {
return $existing;
}
return $this->update((int) $existing['id'], [
'column' => $data['column'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'type' => $data['type'] ?? null,
'color' => $data['color'] ?? null,
]);
}
private function assertValidColumn(mixed $column): void
{
if (!is_string($column) || !in_array($column, self::COLUMNS, true)) {
throw new InvalidArgumentException('Ungueltige Spalte: ' . var_export($column, true));
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Sync;
interface CodebergClient
{
/**
* Liefert Issues eines Repos fuer den angegebenen Zustand ("open" oder "closed"),
* jeweils mit den Feldern: number, title, body, labels (Liste aus name/color).
*
* @return array<int, array{number: int, title: string, body: ?string, labels: array<int, array{name: string, color: string}>}>
*/
public function listIssues(string $owner, string $repo, string $state): array;
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Sync;
use Monitor\Api\Repository\TicketRepository;
/**
* F4: "Backlog und Done werden regelmaessig von Codeberg ausgelesen." Offene Issues landen
* (falls noch nicht vorhanden bzw. noch nicht manuell bewegt) in Backlog, geschlossene in Done.
* Ticket-Typ und Farbe werden aus dem ersten Codeberg-Label uebernommen (F4: "Ticket-Typen ...
* koennen mit den Codeberg-Farben dargestellt werden").
*/
final class CodebergSyncService
{
public function __construct(
private readonly CodebergClient $client,
private readonly TicketRepository $tickets,
private readonly string $owner,
private readonly string $repo,
) {
}
public function sync(): void
{
foreach ($this->client->listIssues($this->owner, $this->repo, 'open') as $issue) {
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'backlog'));
}
foreach ($this->client->listIssues($this->owner, $this->repo, 'closed') as $issue) {
$this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done'));
}
}
/**
* @param array{number: int, title: string, body: ?string, labels: array<int, array{name: string, color: string}>} $issue
* @return array<string, mixed>
*/
private function mapIssue(array $issue, string $column): array
{
$firstLabel = $issue['labels'][0] ?? null;
return [
'external_ref' => (string) $issue['number'],
'title' => $issue['title'],
'description' => $issue['body'],
'column' => $column,
'type' => $firstLabel['name'] ?? null,
'color' => $firstLabel['color'] ?? null,
];
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Sync;
use RuntimeException;
final class CurlCodebergClient implements CodebergClient
{
private const PER_PAGE = 50;
private const MAX_PAGES = 20;
public function __construct(
private readonly string $apiBase,
private readonly ?string $token = null,
) {
}
public function listIssues(string $owner, string $repo, string $state): array
{
$issues = [];
for ($page = 1; $page <= self::MAX_PAGES; $page++) {
$url = sprintf(
'%s/repos/%s/%s/issues?state=%s&type=issues&page=%d&limit=%d',
$this->apiBase,
rawurlencode($owner),
rawurlencode($repo),
rawurlencode($state),
$page,
self::PER_PAGE
);
$pageIssues = $this->fetch($url);
if ($pageIssues === []) {
break;
}
foreach ($pageIssues as $issue) {
$issues[] = [
'number' => (int) $issue['number'],
'title' => (string) $issue['title'],
'body' => isset($issue['body']) ? (string) $issue['body'] : null,
'labels' => array_map(
static fn (array $label): array => [
'name' => (string) $label['name'],
'color' => '#' . ltrim((string) $label['color'], '#'),
],
$issue['labels'] ?? []
),
];
}
if (count($pageIssues) < self::PER_PAGE) {
break;
}
}
return $issues;
}
/** @return array<int, array<string, mixed>> */
private function fetch(string $url): array
{
$headers = ['Accept: application/json'];
if ($this->token !== null && $this->token !== '') {
$headers[] = 'Authorization: token ' . $this->token;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 15,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false) {
throw new RuntimeException('Codeberg-API nicht erreichbar: ' . $error);
}
if ($status >= 400) {
throw new RuntimeException('Codeberg-API-Fehler (HTTP ' . $status . '): ' . $body);
}
$decoded = json_decode((string) $body, true);
return is_array($decoded) ? $decoded : [];
}
}

View file

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

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Auth;
use Monitor\Api\Auth\AdminAuthenticator;
use Monitor\Api\Http\Request;
use PHPUnit\Framework\TestCase;
final class AdminAuthenticatorTest extends TestCase
{
public function testAuthenticateAcceptsCorrectKey(): void
{
$auth = new AdminAuthenticator('secret');
self::assertTrue($auth->authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'secret'])));
}
public function testAuthenticateRejectsWrongKey(): void
{
$auth = new AdminAuthenticator('secret');
self::assertFalse($auth->authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'wrong'])));
}
public function testAuthenticateRejectsWhenNoAdminKeyConfigured(): void
{
$auth = new AdminAuthenticator(null);
self::assertFalse($auth->authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'anything'])));
}
public function testAuthenticateRejectsMissingHeader(): void
{
$auth = new AdminAuthenticator('secret');
self::assertFalse($auth->authenticate(Request::create('POST', '/x')));
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Auth;
use Monitor\Api\Auth\AgentAuthenticator;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\AgentRepository;
use PHPUnit\Framework\TestCase;
final class AgentAuthenticatorTest extends TestCase
{
public function testAuthenticateReturnsAgentForValidKey(): void
{
$agents = new AgentRepository(Database::inMemory());
$registration = $agents->register('Alice', 'entwickler');
$auth = new AgentAuthenticator($agents);
$request = Request::create('GET', '/x', headers: ['X-Api-Key' => $registration['api_key']]);
$result = $auth->authenticate($request);
self::assertNotNull($result);
self::assertSame($registration['agent']['id'], $result['id']);
}
public function testAuthenticateReturnsNullWithoutHeader(): void
{
$agents = new AgentRepository(Database::inMemory());
$auth = new AgentAuthenticator($agents);
self::assertNull($auth->authenticate(Request::create('GET', '/x')));
}
public function testAuthenticateReturnsNullForWrongKey(): void
{
$agents = new AgentRepository(Database::inMemory());
$agents->register('Alice', 'entwickler');
$auth = new AgentAuthenticator($agents);
$request = Request::create('GET', '/x', headers: ['X-Api-Key' => 'wrong']);
self::assertNull($auth->authenticate($request));
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Auth;
use Monitor\Api\Auth\ApiKeyGenerator;
use PHPUnit\Framework\TestCase;
final class ApiKeyGeneratorTest extends TestCase
{
public function testGenerateProducesUnique64CharHexKeys(): void
{
$a = ApiKeyGenerator::generate();
$b = ApiKeyGenerator::generate();
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $a);
self::assertNotSame($a, $b);
}
public function testHashIsDeterministicSha256(): void
{
$key = 'test-key';
self::assertSame(hash('sha256', $key), ApiKeyGenerator::hash($key));
self::assertSame(ApiKeyGenerator::hash($key), ApiKeyGenerator::hash($key));
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests;
use Monitor\Api\Config;
use PHPUnit\Framework\TestCase;
final class ConfigTest extends TestCase
{
public function testDefaultsAreUsedWhenNothingConfigured(): void
{
$config = new Config([]);
self::assertSame('https://codeberg.org/api/v1', $config->codebergApiBase());
self::assertSame('fds2610', $config->codebergOwner());
self::assertSame('monitor', $config->codebergRepo());
self::assertNull($config->codebergToken());
self::assertNull($config->adminApiKey());
self::assertSame(8, $config->deskCount());
}
public function testValuesCanBeOverridden(): void
{
$config = new Config([
'CODEBERG_API_BASE' => 'https://example.org/api/v1/',
'CODEBERG_OWNER' => 'someone',
'CODEBERG_REPO' => 'somerepo',
'CODEBERG_TOKEN' => 'tok',
'MONITOR_ADMIN_API_KEY' => 'secret',
'MONITOR_DESK_COUNT' => '3',
]);
self::assertSame('https://example.org/api/v1', $config->codebergApiBase());
self::assertSame('someone', $config->codebergOwner());
self::assertSame('somerepo', $config->codebergRepo());
self::assertSame('tok', $config->codebergToken());
self::assertSame('secret', $config->adminApiKey());
self::assertSame(3, $config->deskCount());
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\AgentController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\AgentRepository;
use PHPUnit\Framework\TestCase;
final class AgentControllerTest extends TestCase
{
private AgentController $controller;
private AgentRepository $repository;
protected function setUp(): void
{
$this->repository = new AgentRepository(Database::inMemory());
$this->controller = new AgentController($this->repository);
}
public function testRegisterRejectsMissingDisplayName(): void
{
$response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: ['role' => 'entwickler']));
self::assertSame(422, $response->status);
}
public function testRegisterRejectsUnknownRole(): void
{
$response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: [
'display_name' => 'Alice', 'role' => 'ceo',
]));
self::assertSame(422, $response->status);
}
public function testRegisterReturns201WithApiKey(): void
{
$response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: [
'display_name' => 'Alice', 'role' => 'entwickler',
]));
self::assertSame(201, $response->status);
self::assertArrayHasKey('api_key', $response->body());
self::assertArrayNotHasKey('api_key_hash', $response->body()['agent']);
}
public function testActivityRejectsMismatchedAgentId(): void
{
$registration = $this->repository->register('Alice', 'entwickler');
$other = $this->repository->register('Bob', 'qa');
$response = $this->controller->activity(
Request::create('POST', '/x', body: ['state' => 'working']),
['id' => (string) $other['agent']['id']],
$registration['agent']
);
self::assertSame(403, $response->status);
}
public function testActivityRejectsMissingState(): void
{
$registration = $this->repository->register('Alice', 'entwickler');
$response = $this->controller->activity(
Request::create('POST', '/x', body: []),
['id' => (string) $registration['agent']['id']],
$registration['agent']
);
self::assertSame(422, $response->status);
}
public function testActivityUpdatesOwnState(): void
{
$registration = $this->repository->register('Alice', 'entwickler');
$response = $this->controller->activity(
Request::create('POST', '/x', body: ['state' => 'working', 'current_ticket' => 7]),
['id' => (string) $registration['agent']['id']],
$registration['agent']
);
self::assertSame(200, $response->status);
self::assertSame('working', $response->body()['agent']['state']);
self::assertSame(7, $response->body()['agent']['current_ticket_id']);
}
public function testIndexListsAgents(): void
{
$this->repository->register('Alice', 'entwickler');
$response = $this->controller->index(Request::create('GET', '/api/v1/agents'));
self::assertCount(1, $response->body()['agents']);
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\EventController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\EventRepository;
use PHPUnit\Framework\TestCase;
final class EventControllerTest extends TestCase
{
private EventController $controller;
protected function setUp(): void
{
$this->controller = new EventController(new EventRepository(Database::inMemory()));
}
public function testCreateRejectsMissingFields(): void
{
$response = $this->controller->create(Request::create('POST', '/api/v1/events', body: ['name' => 'X']));
self::assertSame(422, $response->status);
}
public function testCreateRejectsInvalidSeverity(): void
{
$response = $this->controller->create(Request::create('POST', '/api/v1/events', body: [
'name' => 'X', 'message' => 'Y', 'severity' => 'info',
]));
self::assertSame(422, $response->status);
}
public function testCreateReturns201(): void
{
$response = $this->controller->create(Request::create('POST', '/api/v1/events', body: [
'name' => 'Ping', 'message' => 'down', 'severity' => 'error',
]));
self::assertSame(201, $response->status);
self::assertSame('error', $response->body()['event']['severity']);
}
public function testIndexReturnsEvents(): void
{
$this->controller->create(Request::create('POST', '/api/v1/events', body: [
'name' => 'Ping', 'message' => 'down', 'severity' => 'error',
]));
$response = $this->controller->index(Request::create('GET', '/api/v1/events'));
self::assertCount(1, $response->body()['events']);
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\KpiController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\KpiRepository;
use PHPUnit\Framework\TestCase;
final class KpiControllerTest extends TestCase
{
private KpiController $controller;
protected function setUp(): void
{
$this->controller = new KpiController(new KpiRepository(Database::inMemory()));
}
public function testReportRejectsMissingValue(): void
{
$response = $this->controller->report(Request::create('POST', '/api/v1/kpis/open_tickets', body: []), ['key' => 'open_tickets']);
self::assertSame(422, $response->status);
}
public function testReportRejectsNonNumericValue(): void
{
$response = $this->controller->report(
Request::create('POST', '/api/v1/kpis/open_tickets', body: ['value' => 'abc']),
['key' => 'open_tickets']
);
self::assertSame(422, $response->status);
}
public function testReportReturns201WithKpi(): void
{
$response = $this->controller->report(
Request::create('POST', '/api/v1/kpis/open_tickets', body: ['value' => 3]),
['key' => 'open_tickets']
);
self::assertSame(201, $response->status);
self::assertSame(3.0, $response->body()['kpi']['current_value']);
}
public function testIndexListsKpis(): void
{
$this->controller->report(Request::create('POST', '/x', body: ['value' => 1]), ['key' => 'a']);
$response = $this->controller->index(Request::create('GET', '/api/v1/kpis'));
self::assertCount(1, $response->body()['kpis']);
}
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\SystemController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\LegacyStatusFileReader;
use Monitor\Api\Repository\SystemRepository;
use PHPUnit\Framework\TestCase;
final class SystemControllerTest extends TestCase
{
private SystemController $controller;
private string $tmpFile;
protected function setUp(): void
{
$this->tmpFile = tempnam(sys_get_temp_dir(), 'status_');
file_put_contents($this->tmpFile, "Server1 1.2.3.4 online 2026-07-27 10:00\n");
$this->controller = new SystemController(
new SystemRepository(Database::inMemory()),
new LegacyStatusFileReader(),
$this->tmpFile
);
}
protected function tearDown(): void
{
if (is_file($this->tmpFile)) {
unlink($this->tmpFile);
}
}
public function testIndexImportsLegacyStatusFile(): void
{
$response = $this->controller->index(Request::create('GET', '/api/v1/systems'));
self::assertSame(200, $response->status);
self::assertCount(1, $response->body()['systems']);
self::assertSame('Server1', $response->body()['systems'][0]['id']);
}
public function testReportStatusRejectsInvalidStatus(): void
{
$response = $this->controller->reportStatus(
Request::create('POST', '/api/v1/systems/srv1/status', body: ['status' => 'weird']),
['id' => 'srv1']
);
self::assertSame(422, $response->status);
}
public function testReportStatusReturnsUpdatedSystem(): void
{
$response = $this->controller->reportStatus(
Request::create('POST', '/api/v1/systems/srv1/status', body: ['status' => 'ok']),
['id' => 'srv1']
);
self::assertSame(200, $response->status);
self::assertSame('ok', $response->body()['system']['status']);
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\TicketController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\TicketRepository;
use PHPUnit\Framework\TestCase;
final class TicketControllerTest extends TestCase
{
private TicketController $controller;
private TicketRepository $repository;
protected function setUp(): void
{
$this->repository = new TicketRepository(Database::inMemory());
$this->controller = new TicketController($this->repository);
}
public function testIndexReturnsAllTickets(): void
{
$this->repository->create(['title' => 'A']);
$response = $this->controller->index(Request::create('GET', '/api/v1/tickets'));
self::assertSame(200, $response->status);
self::assertCount(1, $response->body()['tickets']);
}
public function testCreateRejectsMissingTitle(): void
{
$response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: []));
self::assertSame(422, $response->status);
}
public function testCreateReturns201WithTicket(): void
{
$response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: ['title' => 'Neu']));
self::assertSame(201, $response->status);
self::assertSame('Neu', $response->body()['ticket']['title']);
}
public function testCreateRejectsInvalidColumnWith422(): void
{
$response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: ['title' => 'X', 'column' => 'invalid']));
self::assertSame(422, $response->status);
}
public function testUpdateMovesTicket(): void
{
$ticket = $this->repository->create(['title' => 'X']);
$response = $this->controller->update(
Request::create('PATCH', '/api/v1/tickets/' . $ticket['id'], body: ['column' => 'bereit']),
['id' => (string) $ticket['id']]
);
self::assertSame(200, $response->status);
self::assertSame('bereit', $response->body()['ticket']['column_name']);
}
public function testUpdatePropagatesNotFoundForUnknownTicket(): void
{
$this->expectException(NotFoundException::class);
$this->controller->update(
Request::create('PATCH', '/api/v1/tickets/999', body: ['column' => 'bereit']),
['id' => '999']
);
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Db;
use Monitor\Api\Db\Database;
use PHPUnit\Framework\TestCase;
final class DatabaseTest extends TestCase
{
public function testMigrateCreatesAllTables(): void
{
$db = Database::inMemory();
$stmt = $db->pdo()->query("SELECT name FROM sqlite_master WHERE type = 'table'");
$tables = array_column($stmt->fetchAll(), 'name');
foreach (['agents', 'tickets', 'events', 'systems', 'kpis', 'kpi_values'] as $expected) {
self::assertContains($expected, $tables);
}
}
public function testMigrateIsIdempotent(): void
{
$db = Database::inMemory();
// Zweiter Aufruf darf nicht fehlschlagen (CREATE TABLE IF NOT EXISTS).
$db->migrate();
self::assertTrue(true);
}
public function testNowReturnsIso8601Utc(): void
{
$db = Database::inMemory();
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $db->now());
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Http;
use Monitor\Api\Http\Request;
use PHPUnit\Framework\TestCase;
final class RequestTest extends TestCase
{
public function testCreateNormalizesTrailingSlash(): void
{
$request = Request::create('GET', '/api/v1/tickets/');
self::assertSame('/api/v1/tickets', $request->path);
}
public function testCreateKeepsRootPath(): void
{
$request = Request::create('GET', '/');
self::assertSame('/', $request->path);
}
public function testHeaderLookupIsCaseInsensitive(): void
{
$request = Request::create('GET', '/x', headers: ['X-Api-Key' => 'abc']);
self::assertSame('abc', $request->header('x-api-key'));
self::assertSame('abc', $request->header('X-API-KEY'));
}
public function testHeaderReturnsNullWhenMissing(): void
{
$request = Request::create('GET', '/x');
self::assertNull($request->header('x-api-key'));
}
public function testCreateStoresQueryAndBody(): void
{
$request = Request::create('GET', '/x', query: ['limit' => '5'], body: ['title' => 'Hi']);
self::assertSame(['limit' => '5'], $request->query);
self::assertSame(['title' => 'Hi'], $request->body);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Http;
use Monitor\Api\Http\Response;
use PHPUnit\Framework\TestCase;
final class ResponseTest extends TestCase
{
public function testJsonResponseCarriesStatusAndBody(): void
{
$response = Response::json(['foo' => 'bar'], 201);
self::assertSame(201, $response->status);
self::assertSame(['foo' => 'bar'], $response->body());
}
public function testErrorResponseDefaultsToGivenStatus(): void
{
$response = Response::error('nope', 404);
self::assertSame(404, $response->status);
self::assertSame(['error' => 'nope'], $response->body());
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Http;
use Monitor\Api\Http\MethodNotAllowedException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Router;
use PHPUnit\Framework\TestCase;
final class RouterTest extends TestCase
{
public function testMatchesStaticRoute(): void
{
$router = new Router();
$router->add('GET', '/api/v1/tickets', fn () => 'tickets');
$match = $router->match(Request::create('GET', '/api/v1/tickets'));
self::assertNotNull($match);
self::assertSame('tickets', ($match->handler)());
}
public function testExtractsPathParams(): void
{
$router = new Router();
$router->add('PATCH', '/api/v1/tickets/{id}', fn () => null);
$match = $router->match(Request::create('PATCH', '/api/v1/tickets/42'));
self::assertSame(['id' => '42'], $match->params);
}
public function testReturnsNullForUnknownPath(): void
{
$router = new Router();
$router->add('GET', '/api/v1/tickets', fn () => null);
self::assertNull($router->match(Request::create('GET', '/does/not/exist')));
}
public function testThrowsMethodNotAllowedForKnownPathWrongMethod(): void
{
$router = new Router();
$router->add('GET', '/api/v1/tickets', fn () => null);
$this->expectException(MethodNotAllowedException::class);
$router->match(Request::create('DELETE', '/api/v1/tickets'));
}
public function testCarriesAuthRequirement(): void
{
$router = new Router();
$router->add('POST', '/api/v1/tickets', fn () => null, 'agent');
$match = $router->match(Request::create('POST', '/api/v1/tickets'));
self::assertSame('agent', $match->auth);
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Repository\AgentRepository;
use PHPUnit\Framework\TestCase;
final class AgentRepositoryTest extends TestCase
{
private AgentRepository $repository;
protected function setUp(): void
{
$this->repository = new AgentRepository(Database::inMemory(), deskCount: 2);
}
public function testRegisterAssignsLowestFreeDesk(): void
{
$first = $this->repository->register('Alice', 'entwickler');
$second = $this->repository->register('Bob', 'qa');
self::assertSame(0, $first['agent']['desk_index']);
self::assertSame(1, $second['agent']['desk_index']);
self::assertNotSame($first['api_key'], $second['api_key']);
self::assertArrayNotHasKey('api_key_hash', $first['agent']);
}
public function testUserRoleNeverGetsADesk(): void
{
$result = $this->repository->register('Carol', 'user');
self::assertNull($result['agent']['desk_index']);
}
public function testOverflowAgentGetsNoDeskWhenAllOccupied(): void
{
$this->repository->register('Alice', 'entwickler');
$this->repository->register('Bob', 'qa');
$third = $this->repository->register('Dan', 'product_owner');
self::assertNull($third['agent']['desk_index']);
}
public function testRegisterRejectsUnknownRole(): void
{
$this->expectException(InvalidArgumentException::class);
$this->repository->register('Eve', 'ceo');
}
public function testFindByApiKeyReturnsMatchingAgent(): void
{
$result = $this->repository->register('Alice', 'entwickler');
$found = $this->repository->findByApiKey($result['api_key']);
self::assertNotNull($found);
self::assertSame($result['agent']['id'], $found['id']);
}
public function testFindByApiKeyReturnsNullForUnknownKey(): void
{
self::assertNull($this->repository->findByApiKey('does-not-exist'));
}
public function testUpdateActivityChangesStateAndTicket(): void
{
$result = $this->repository->register('Alice', 'entwickler');
$id = (int) $result['agent']['id'];
$updated = $this->repository->updateActivity($id, 'working', 42);
self::assertSame('working', $updated['state']);
self::assertSame(42, $updated['current_ticket_id']);
}
public function testUpdateActivityThrowsForUnknownAgent(): void
{
$this->expectException(NotFoundException::class);
$this->repository->updateActivity(999, 'working', null);
}
public function testAllListsAgentsWithoutApiKeyHash(): void
{
$this->repository->register('Alice', 'entwickler');
$this->repository->register('Bob', 'qa');
$all = $this->repository->all();
self::assertCount(2, $all);
foreach ($all as $agent) {
self::assertArrayNotHasKey('api_key_hash', $agent);
}
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Repository\EventRepository;
use PHPUnit\Framework\TestCase;
final class EventRepositoryTest extends TestCase
{
private EventRepository $repository;
protected function setUp(): void
{
$this->repository = new EventRepository(Database::inMemory());
}
public function testCreateStoresEvent(): void
{
$event = $this->repository->create([
'system_id' => 'srv1',
'name' => 'Ping',
'message' => 'Host nicht erreichbar',
'severity' => 'error',
]);
self::assertSame('error', $event['severity']);
self::assertSame('srv1', $event['system_id']);
}
public function testCreateRejectsInvalidSeverity(): void
{
$this->expectException(InvalidArgumentException::class);
$this->repository->create(['name' => 'X', 'message' => 'Y', 'severity' => 'info']);
}
public function testLatestReturnsNewestFirst(): void
{
$this->repository->create(['name' => 'A', 'message' => 'a', 'severity' => 'warning', 'occurred_at' => '2026-01-01T00:00:00Z']);
$this->repository->create(['name' => 'B', 'message' => 'b', 'severity' => 'error', 'occurred_at' => '2026-01-02T00:00:00Z']);
$latest = $this->repository->latest();
self::assertSame('B', $latest[0]['name']);
self::assertSame('A', $latest[1]['name']);
}
public function testLatestRespectsDefaultLimitOfFive(): void
{
for ($i = 0; $i < 10; $i++) {
$this->repository->create([
'name' => 'Event ' . $i,
'message' => 'msg',
'severity' => 'warning',
'occurred_at' => sprintf('2026-01-01T00:00:%02dZ', $i),
]);
}
self::assertCount(EventRepository::DEFAULT_LIMIT, $this->repository->latest());
}
public function testLatestClampsLimitToAtLeastOne(): void
{
$this->repository->create(['name' => 'A', 'message' => 'a', 'severity' => 'warning']);
self::assertCount(1, $this->repository->latest(0));
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use Monitor\Api\Db\Database;
use Monitor\Api\Repository\KpiRepository;
use PHPUnit\Framework\TestCase;
final class KpiRepositoryTest extends TestCase
{
private KpiRepository $repository;
protected function setUp(): void
{
$this->repository = new KpiRepository(Database::inMemory(), historyLimit: 3);
}
public function testReportCreatesKpiWithCurrentValue(): void
{
$kpi = $this->repository->report('open_tickets', 5.0, 'Offene Tickets', 'count', '2026-01-01T00:00:00Z');
self::assertSame('open_tickets', $kpi['key']);
self::assertSame(5.0, $kpi['current_value']);
self::assertCount(1, $kpi['history']);
}
public function testReportAppendsHistoryAndUpdatesCurrentValue(): void
{
$this->repository->report('open_tickets', 5.0, null, null, '2026-01-01T00:00:00Z');
$kpi = $this->repository->report('open_tickets', 7.0, null, null, '2026-01-02T00:00:00Z');
self::assertSame(7.0, $kpi['current_value']);
self::assertCount(2, $kpi['history']);
}
public function testHistoryIsLimited(): void
{
for ($i = 0; $i < 5; $i++) {
$this->repository->report('kpi', (float) $i, null, null, sprintf('2026-01-0%dT00:00:00Z', $i + 1));
}
$kpi = $this->repository->find('kpi');
self::assertCount(3, $kpi['history']);
}
public function testFindReturnsNullForUnknownKey(): void
{
self::assertNull($this->repository->find('unknown'));
}
public function testAllListsAllKpis(): void
{
$this->repository->report('a', 1.0, null, null, '2026-01-01T00:00:00Z');
$this->repository->report('b', 2.0, null, null, '2026-01-01T00:00:00Z');
self::assertCount(2, $this->repository->all());
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use Monitor\Api\Repository\LegacyStatusFileReader;
use PHPUnit\Framework\TestCase;
final class LegacyStatusFileReaderTest extends TestCase
{
private string $tmpFile;
protected function setUp(): void
{
$this->tmpFile = tempnam(sys_get_temp_dir(), 'status_');
}
protected function tearDown(): void
{
if (is_file($this->tmpFile)) {
unlink($this->tmpFile);
}
}
public function testReadsValidLines(): void
{
file_put_contents($this->tmpFile, "Server1 192.168.178.2 online 2026-07-27 10:00\nServer2 192.168.178.3 offline 2026-07-27 10:01\n");
$rows = (new LegacyStatusFileReader())->read($this->tmpFile);
self::assertCount(2, $rows);
self::assertSame('Server1', $rows[0]['id']);
self::assertSame('ok', $rows[0]['status']);
self::assertSame('192.168.178.2', $rows[0]['detail']);
self::assertSame('2026-07-27 10:00', $rows[0]['last_check_at']);
self::assertSame('error', $rows[1]['status']);
}
public function testSkipsMalformedLines(): void
{
file_put_contents($this->tmpFile, "incomplete line\nServer1 1.2.3.4 online 2026-07-27 10:00\n");
$rows = (new LegacyStatusFileReader())->read($this->tmpFile);
self::assertCount(1, $rows);
}
public function testReturnsEmptyArrayForMissingFile(): void
{
self::assertSame([], (new LegacyStatusFileReader())->read('/does/not/exist.txt'));
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Repository\SystemRepository;
use PHPUnit\Framework\TestCase;
final class SystemRepositoryTest extends TestCase
{
private SystemRepository $repository;
protected function setUp(): void
{
$this->repository = new SystemRepository(Database::inMemory());
}
public function testReportStatusInsertsSystem(): void
{
$system = $this->repository->reportStatus('srv1', ['status' => 'ok', 'name' => 'Server 1']);
self::assertSame('ok', $system['status']);
self::assertSame('api', $system['source']);
}
public function testReportStatusRejectsInvalidStatus(): void
{
$this->expectException(InvalidArgumentException::class);
$this->repository->reportStatus('srv1', ['status' => 'unknown']);
}
public function testReportStatusUpdatesExistingSystem(): void
{
$this->repository->reportStatus('srv1', ['status' => 'ok']);
$updated = $this->repository->reportStatus('srv1', ['status' => 'error', 'detail' => 'Timeout']);
self::assertSame('error', $updated['status']);
self::assertSame('Timeout', $updated['detail']);
}
public function testImportLegacyRowsDoesNotOverwriteApiSourcedSystem(): void
{
$this->repository->reportStatus('srv1', ['status' => 'ok']);
$this->repository->importLegacyRows([
['id' => 'srv1', 'name' => 'srv1', 'status' => 'error', 'detail' => '1.2.3.4', 'last_check_at' => '2026-01-01 00:00'],
]);
$system = $this->repository->find('srv1');
self::assertSame('ok', $system['status']);
self::assertSame('api', $system['source']);
}
public function testImportLegacyRowsCreatesNewLegacySystem(): void
{
$this->repository->importLegacyRows([
['id' => 'srv2', 'name' => 'srv2', 'status' => 'ok', 'detail' => '1.2.3.5', 'last_check_at' => '2026-01-01 00:00'],
]);
$system = $this->repository->find('srv2');
self::assertNotNull($system);
self::assertSame('legacy', $system['source']);
}
public function testAllOrdersErrorsFirstAndLimitsToNineTiles(): void
{
for ($i = 0; $i < 12; $i++) {
$this->repository->reportStatus('srv' . $i, ['status' => 'ok']);
}
$this->repository->reportStatus('broken', ['status' => 'error']);
$all = $this->repository->all();
self::assertCount(SystemRepository::MAX_TILES, $all);
self::assertSame('error', $all[0]['status']);
}
}

View file

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Repository;
use InvalidArgumentException;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\NotFoundException;
use Monitor\Api\Repository\TicketRepository;
use PHPUnit\Framework\TestCase;
final class TicketRepositoryTest extends TestCase
{
private TicketRepository $repository;
protected function setUp(): void
{
$this->repository = new TicketRepository(Database::inMemory());
}
public function testCreateDefaultsToBacklogColumn(): void
{
$ticket = $this->repository->create(['title' => 'Neues Ticket']);
self::assertSame('backlog', $ticket['column_name']);
self::assertSame('Neues Ticket', $ticket['title']);
}
public function testCreateRejectsUnknownColumn(): void
{
$this->expectException(InvalidArgumentException::class);
$this->repository->create(['title' => 'X', 'column' => 'unknown']);
}
public function testUpdateMovesTicketToAnotherColumn(): void
{
$ticket = $this->repository->create(['title' => 'X']);
$updated = $this->repository->update((int) $ticket['id'], ['column' => 'in_arbeit']);
self::assertSame('in_arbeit', $updated['column_name']);
}
public function testUpdateRejectsUnknownColumn(): void
{
$ticket = $this->repository->create(['title' => 'X']);
$this->expectException(InvalidArgumentException::class);
$this->repository->update((int) $ticket['id'], ['column' => 'unknown']);
}
public function testUpdateThrowsNotFoundForMissingTicket(): void
{
$this->expectException(NotFoundException::class);
$this->repository->update(999, ['column' => 'bereit']);
}
public function testAllFiltersByColumn(): void
{
$this->repository->create(['title' => 'A', 'column' => 'backlog']);
$this->repository->create(['title' => 'B', 'column' => 'done']);
$backlog = $this->repository->all('backlog');
self::assertCount(1, $backlog);
self::assertSame('A', $backlog[0]['title']);
}
public function testAllOrdersByColumnThenCreatedAt(): void
{
$this->repository->create(['title' => 'first', 'column' => 'backlog']);
$this->repository->create(['title' => 'second', 'column' => 'backlog']);
$tickets = $this->repository->all();
self::assertSame('first', $tickets[0]['title']);
self::assertSame('second', $tickets[1]['title']);
}
public function testUpsertFromCodebergCreatesNewTicket(): void
{
$ticket = $this->repository->upsertFromCodeberg([
'external_ref' => '42',
'title' => 'Issue 42',
'description' => 'Body',
'column' => 'backlog',
'type' => 'bug',
'color' => '#ff0000',
]);
self::assertSame('42', $ticket['external_ref']);
self::assertSame('codeberg', $ticket['source']);
}
public function testUpsertFromCodebergUpdatesExistingBacklogTicket(): void
{
$this->repository->upsertFromCodeberg([
'external_ref' => '42',
'title' => 'Issue 42',
'column' => 'backlog',
]);
$updated = $this->repository->upsertFromCodeberg([
'external_ref' => '42',
'title' => 'Issue 42 (closed)',
'column' => 'done',
]);
self::assertSame('done', $updated['column_name']);
self::assertSame('Issue 42 (closed)', $updated['title']);
}
public function testUpsertFromCodebergDoesNotResetManuallyMovedTicket(): void
{
$this->repository->upsertFromCodeberg([
'external_ref' => '42',
'title' => 'Issue 42',
'column' => 'backlog',
]);
$ticket = $this->repository->findByExternalRef('42');
$this->repository->update((int) $ticket['id'], ['column' => 'in_arbeit']);
$result = $this->repository->upsertFromCodeberg([
'external_ref' => '42',
'title' => 'Issue 42 geaendert',
'column' => 'backlog',
]);
self::assertSame('in_arbeit', $result['column_name']);
}
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Sync;
use Monitor\Api\Db\Database;
use Monitor\Api\Repository\TicketRepository;
use Monitor\Api\Sync\CodebergClient;
use Monitor\Api\Sync\CodebergSyncService;
use PHPUnit\Framework\TestCase;
final class FakeCodebergClient implements CodebergClient
{
/** @param array<string, array<int, array{number:int,title:string,body:?string,labels:array}>> $issuesByState */
public function __construct(private readonly array $issuesByState)
{
}
public function listIssues(string $owner, string $repo, string $state): array
{
return $this->issuesByState[$state] ?? [];
}
}
final class CodebergSyncServiceTest extends TestCase
{
public function testSyncCreatesBacklogAndDoneTickets(): void
{
$tickets = new TicketRepository(Database::inMemory());
$client = new FakeCodebergClient([
'open' => [
['number' => 1, 'title' => 'Offenes Issue', 'body' => 'Beschreibung', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]],
],
'closed' => [
['number' => 2, 'title' => 'Geschlossenes Issue', 'body' => null, 'labels' => []],
],
]);
(new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync();
$backlog = $tickets->all('backlog');
$done = $tickets->all('done');
self::assertCount(1, $backlog);
self::assertSame('Offenes Issue', $backlog[0]['title']);
self::assertSame('bug', $backlog[0]['type']);
self::assertSame('#ff0000', $backlog[0]['color']);
self::assertSame('codeberg', $backlog[0]['source']);
self::assertCount(1, $done);
self::assertSame('Geschlossenes Issue', $done[0]['title']);
self::assertNull($done[0]['type']);
}
public function testSyncDoesNotResetManuallyMovedTicket(): void
{
$tickets = new TicketRepository(Database::inMemory());
$client = new FakeCodebergClient([
'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]],
'closed' => [],
]);
$service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor');
$service->sync();
$ticket = $tickets->findByExternalRef('1');
$tickets->update((int) $ticket['id'], ['column' => 'qa']);
$service->sync();
$stillQa = $tickets->findByExternalRef('1');
self::assertSame('qa', $stillQa['column_name']);
}
}

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
// Stubs fuer die curl_*-Funktionen im Namespace von CurlCodebergClient: PHP loest
// unqualifizierte Funktionsaufrufe zuerst im aktuellen Namespace auf, bevor es auf den
// globalen Namespace zurueckfaellt. Damit lassen sich HTTP-Aufrufe ohne echtes Netzwerk
// und ohne zusaetzliche Mocking-Bibliothek testen.
namespace Monitor\Api\Sync {
final class CurlStub
{
/** @var array<int, array{status: int, body: string|false, error?: string}> */
public static array $queue = [];
/** @var string[] */
public static array $requestedUrls = [];
public static function reset(): void
{
self::$queue = [];
self::$requestedUrls = [];
}
}
function curl_init(string $url = '')
{
CurlStub::$requestedUrls[] = $url;
$handle = new \stdClass();
$handle->response = array_shift(CurlStub::$queue) ?? ['status' => 200, 'body' => '[]'];
return $handle;
}
function curl_setopt_array($handle, array $options): bool
{
return true;
}
function curl_exec($handle)
{
return $handle->response['body'];
}
function curl_getinfo($handle, int $option)
{
return $handle->response['status'];
}
function curl_error($handle): string
{
return $handle->response['error'] ?? '';
}
function curl_close($handle): void
{
}
}
namespace Monitor\Api\Tests\Sync {
use Monitor\Api\Sync\CurlCodebergClient;
use Monitor\Api\Sync\CurlStub;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class CurlCodebergClientTest extends TestCase
{
protected function setUp(): void
{
CurlStub::reset();
}
public function testListIssuesParsesSinglePage(): void
{
CurlStub::$queue = [
['status' => 200, 'body' => json_encode([
['number' => 1, 'title' => 'A', 'body' => 'desc', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]],
])],
];
$client = new CurlCodebergClient('https://codeberg.org/api/v1');
$issues = $client->listIssues('fds2610', 'monitor', 'open');
self::assertCount(1, $issues);
self::assertSame(1, $issues[0]['number']);
self::assertSame('#ff0000', $issues[0]['labels'][0]['color']);
self::assertStringContainsString('state=open', CurlStub::$requestedUrls[0]);
}
public function testListIssuesStopsWhenPageEmpty(): void
{
CurlStub::$queue = [
['status' => 200, 'body' => '[]'],
];
$client = new CurlCodebergClient('https://codeberg.org/api/v1');
$issues = $client->listIssues('fds2610', 'monitor', 'closed');
self::assertSame([], $issues);
self::assertCount(1, CurlStub::$requestedUrls);
}
public function testListIssuesSendsAuthorizationHeaderWhenTokenGiven(): void
{
CurlStub::$queue = [['status' => 200, 'body' => '[]']];
$client = new CurlCodebergClient('https://codeberg.org/api/v1', 'my-token');
$client->listIssues('fds2610', 'monitor', 'open');
self::assertNotEmpty(CurlStub::$requestedUrls);
}
public function testThrowsOnHttpError(): void
{
CurlStub::$queue = [['status' => 500, 'body' => 'boom']];
$this->expectException(RuntimeException::class);
(new CurlCodebergClient('https://codeberg.org/api/v1'))->listIssues('fds2610', 'monitor', 'open');
}
}
}

21
docker-compose.yml Normal file
View file

@ -0,0 +1,21 @@
services:
api:
build:
context: ./api
dockerfile: docker/Dockerfile
volumes:
- ./api:/app
# Optional: echte status.txt aus der Produktion zum Testen einbinden, z.B.
# - ./status.txt:/status.txt:ro
working_dir: /app
ports:
- "8080:8080"
environment:
MONITOR_DB_PATH: /app/data/monitor.sqlite
MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
MONITOR_STATUS_FILE: /status.txt
MONITOR_DESK_COUNT: 8
CODEBERG_API_BASE: https://codeberg.org/api/v1
CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}