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:
parent
56d7a8cfac
commit
857fc88834
60 changed files with 3488 additions and 0 deletions
10
.env.example
Normal file
10
.env.example
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
api/vendor/
|
||||||
|
api/data/
|
||||||
|
.env
|
||||||
221
API.md
Normal file
221
API.md
Normal 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
3
api/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
vendor/
|
||||||
|
data/
|
||||||
|
.git/
|
||||||
22
api/README.md
Normal file
22
api/README.md
Normal 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
26
api/bin/sync-codeberg.php
Executable 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
30
api/composer.json
Normal 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
18
api/docker/Dockerfile
Normal 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
10
api/docker/entrypoint.sh
Executable 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
16
api/phpunit.xml
Normal 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
17
api/public/index.php
Normal 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
113
api/src/Application.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
api/src/Auth/AdminAuthenticator.php
Normal file
30
api/src/Auth/AdminAuthenticator.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
api/src/Auth/AgentAuthenticator.php
Normal file
30
api/src/Auth/AgentAuthenticator.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
api/src/Auth/ApiKeyGenerator.php
Normal file
23
api/src/Auth/ApiKeyGenerator.php
Normal 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
71
api/src/Config.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
63
api/src/Controller/AgentController.php
Normal file
63
api/src/Controller/AgentController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
api/src/Controller/EventController.php
Normal file
41
api/src/Controller/EventController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
api/src/Controller/KpiController.php
Normal file
39
api/src/Controller/KpiController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
api/src/Controller/SystemController.php
Normal file
40
api/src/Controller/SystemController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
api/src/Controller/TicketController.php
Normal file
55
api/src/Controller/TicketController.php
Normal 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
66
api/src/Db/Database.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
74
api/src/Db/migrations/001_initial.sql
Normal file
74
api/src/Db/migrations/001_initial.sql
Normal 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);
|
||||||
9
api/src/Http/MethodNotAllowedException.php
Normal file
9
api/src/Http/MethodNotAllowedException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Monitor\Api\Http;
|
||||||
|
|
||||||
|
final class MethodNotAllowedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
9
api/src/Http/NotFoundException.php
Normal file
9
api/src/Http/NotFoundException.php
Normal 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
68
api/src/Http/Request.php
Normal 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
38
api/src/Http/Response.php
Normal 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
73
api/src/Http/Router.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
api/src/Repository/AgentRepository.php
Normal file
131
api/src/Repository/AgentRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
api/src/Repository/EventRepository.php
Normal file
73
api/src/Repository/EventRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
80
api/src/Repository/KpiRepository.php
Normal file
80
api/src/Repository/KpiRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
api/src/Repository/LegacyStatusFileReader.php
Normal file
44
api/src/Repository/LegacyStatusFileReader.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
api/src/Repository/SystemRepository.php
Normal file
105
api/src/Repository/SystemRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
164
api/src/Repository/TicketRepository.php
Normal file
164
api/src/Repository/TicketRepository.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
api/src/Sync/CodebergClient.php
Normal file
16
api/src/Sync/CodebergClient.php
Normal 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;
|
||||||
|
}
|
||||||
53
api/src/Sync/CodebergSyncService.php
Normal file
53
api/src/Sync/CodebergSyncService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
93
api/src/Sync/CurlCodebergClient.php
Normal file
93
api/src/Sync/CurlCodebergClient.php
Normal 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 : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
95
api/tests/Unit/ApplicationTest.php
Normal file
95
api/tests/Unit/ApplicationTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
api/tests/Unit/Auth/AdminAuthenticatorTest.php
Normal file
40
api/tests/Unit/Auth/AdminAuthenticatorTest.php
Normal 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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
47
api/tests/Unit/Auth/AgentAuthenticatorTest.php
Normal file
47
api/tests/Unit/Auth/AgentAuthenticatorTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
api/tests/Unit/Auth/ApiKeyGeneratorTest.php
Normal file
28
api/tests/Unit/Auth/ApiKeyGeneratorTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
42
api/tests/Unit/ConfigTest.php
Normal file
42
api/tests/Unit/ConfigTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
101
api/tests/Unit/Controller/AgentControllerTest.php
Normal file
101
api/tests/Unit/Controller/AgentControllerTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
api/tests/Unit/Controller/EventControllerTest.php
Normal file
58
api/tests/Unit/Controller/EventControllerTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
api/tests/Unit/Controller/KpiControllerTest.php
Normal file
58
api/tests/Unit/Controller/KpiControllerTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
api/tests/Unit/Controller/SystemControllerTest.php
Normal file
67
api/tests/Unit/Controller/SystemControllerTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
api/tests/Unit/Controller/TicketControllerTest.php
Normal file
79
api/tests/Unit/Controller/TicketControllerTest.php
Normal 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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
api/tests/Unit/Db/DatabaseTest.php
Normal file
40
api/tests/Unit/Db/DatabaseTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
48
api/tests/Unit/Http/RequestTest.php
Normal file
48
api/tests/Unit/Http/RequestTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
api/tests/Unit/Http/ResponseTest.php
Normal file
27
api/tests/Unit/Http/ResponseTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
62
api/tests/Unit/Http/RouterTest.php
Normal file
62
api/tests/Unit/Http/RouterTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
api/tests/Unit/Repository/AgentRepositoryTest.php
Normal file
101
api/tests/Unit/Repository/AgentRepositoryTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
api/tests/Unit/Repository/EventRepositoryTest.php
Normal file
72
api/tests/Unit/Repository/EventRepositoryTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
61
api/tests/Unit/Repository/KpiRepositoryTest.php
Normal file
61
api/tests/Unit/Repository/KpiRepositoryTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
53
api/tests/Unit/Repository/LegacyStatusFileReaderTest.php
Normal file
53
api/tests/Unit/Repository/LegacyStatusFileReaderTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
81
api/tests/Unit/Repository/SystemRepositoryTest.php
Normal file
81
api/tests/Unit/Repository/SystemRepositoryTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
api/tests/Unit/Repository/TicketRepositoryTest.php
Normal file
135
api/tests/Unit/Repository/TicketRepositoryTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
api/tests/Unit/Sync/CodebergSyncServiceTest.php
Normal file
74
api/tests/Unit/Sync/CodebergSyncServiceTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
api/tests/Unit/Sync/CurlCodebergClientTest.php
Normal file
121
api/tests/Unit/Sync/CurlCodebergClientTest.php
Normal 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
21
docker-compose.yml
Normal 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:-}
|
||||||
Loading…
Add table
Reference in a new issue