monitor/api/tests/Unit/Controller/VoiceRecordingControllerTest.php
fds2610-bot be1d61350b Sprachaufnahmen: eine Warteschlange fuer alle statt Zuweisung an Agenten
In Produktion lag eine Sprachnachricht unbearbeitet in der Warteschlange des
OPs-Agenten: aufgenommen wurde ueber seine Figur, abgeholt wird aber nur von
der Maschine, auf der whisper.cpp laeuft. Die Agent-Zuweisung hat eine
Zustaendigkeit vorgetaeuscht, die es nicht gibt - welches Ticket in welchem
Repo entsteht, entscheidet der abholende Bot anhand des Transkripts.

- Migration 007: voice_recordings ohne agent_id. Bestehende Aufnahmen behalten
  ihren Status, die haengengebliebene wandert damit in die globale Warteschlange.
- Migration 008: neue Agenten-Rolle "voice" fuer den Voice-Bot.
- Endpunkte ohne Agent im Pfad: POST/GET /api/v1/recordings,
  GET /api/v1/recordings/{id}/audio, PATCH /api/v1/recordings/{id} - und ohne
  Ownership-Check, an dem die Abholung mit fremdem Key bisher scheiterte.
- GET /api/v1/agents traegt pending_recordings jetzt neben der Liste statt an
  jedem Agenten.
- Frontend: nur noch die Figur des Voice-Bots verlinkt auf record.html (ohne
  ?agent=), Zaehler und Mikrofon-Deko sitzen an seinem Schreibtisch. Die
  Aufnahmeseite nennt den abholenden Bot und bittet darum, das Projekt am
  Anfang der Aufnahme zu nennen.

Die Bot-Seite (bin/voice-recording-agent.sh) folgt in fds2610/Agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:41:47 +02:00

199 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api\Tests\Controller;
use Monitor\Api\Controller\VoiceRecordingController;
use Monitor\Api\Db\Database;
use Monitor\Api\Http\Request;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\VoiceRecordingRepository;
use PHPUnit\Framework\TestCase;
final class VoiceRecordingControllerTest extends TestCase
{
private VoiceRecordingController $controller;
private AgentRepository $agents;
private VoiceRecordingRepository $recordings;
private string $storageDir;
private string $tmpUploadFile;
protected function setUp(): void
{
$db = Database::inMemory();
$this->agents = new AgentRepository($db);
$this->storageDir = sys_get_temp_dir() . '/monitor-recordings-controller-' . uniqid();
$this->recordings = new VoiceRecordingRepository($db, $this->storageDir);
$this->controller = new VoiceRecordingController($this->recordings);
$this->tmpUploadFile = sys_get_temp_dir() . '/monitor-upload-' . uniqid();
file_put_contents($this->tmpUploadFile, 'echte audiodaten');
}
protected function tearDown(): void
{
if (file_exists($this->tmpUploadFile)) {
unlink($this->tmpUploadFile);
}
if (is_dir($this->storageDir)) {
foreach (glob($this->storageDir . '/*') ?: [] as $file) {
unlink($file);
}
rmdir($this->storageDir);
}
}
/** @return array<string, mixed> ein $_FILES-artiges Feld fuer Request::create(). */
private function uploadedAudioField(int $error = UPLOAD_ERR_OK): array
{
return [
'name' => 'aufnahme.webm',
'type' => 'audio/webm',
'tmp_name' => $this->tmpUploadFile,
'error' => $error,
'size' => filesize($this->tmpUploadFile),
];
}
/** Der Voice-Bot: die Rolle, die die Warteschlange tatsaechlich abholt. */
private function voiceBot(): array
{
return $this->agents->register('Vox', 'voice')['agent'];
}
public function testUploadRejectsMissingFile(): void
{
$response = $this->controller->upload(Request::create('POST', '/x'));
self::assertSame(422, $response->status);
}
public function testUploadRejectsTooLargeUploadError(): void
{
$response = $this->controller->upload(
Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField(UPLOAD_ERR_INI_SIZE)])
);
self::assertSame(413, $response->status);
}
/**
* Der Upload kennt keinen Empfaenger mehr: kein Agent im Pfad, keine agent_id in der
* Antwort - die Aufnahme liegt danach in der Warteschlange fuer alle.
*/
public function testUploadStoresRecordingWithoutAgent(): void
{
$response = $this->controller->upload(
Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()])
);
self::assertSame(201, $response->status);
self::assertArrayNotHasKey('agent_id', $response->body()['recording']);
self::assertSame('new', $response->body()['recording']['status']);
}
public function testIndexListsTheSharedQueue(): void
{
$this->recordings->store('a', 'audio/webm');
$response = $this->controller->index(Request::create('GET', '/x'), $this->voiceBot());
self::assertSame(200, $response->status);
self::assertCount(1, $response->body()['recordings']);
}
public function testIndexFiltersByStatus(): void
{
$done = $this->recordings->store('a', 'audio/webm');
$this->recordings->store('b', 'audio/webm');
$this->recordings->markDone((int) $done['id']);
$response = $this->controller->index(
Request::create('GET', '/x', query: ['status' => 'new']),
$this->voiceBot()
);
self::assertSame(200, $response->status);
self::assertCount(1, $response->body()['recordings']);
}
public function testIndexRejectsUnknownStatus(): void
{
$response = $this->controller->index(
Request::create('GET', '/x', query: ['status' => 'archived']),
$this->voiceBot()
);
self::assertSame(400, $response->status);
}
/**
* Jeder authentifizierte Agent kommt an jede Aufnahme - frueher hing der Zugriff an der
* agent_id der Aufnahme, und genau daran blieb in Produktion eine Aufnahme haengen, die
* an einen Agenten ohne Whisper adressiert war.
*/
public function testDownloadReturnsAudioBytesForAnyAgent(): void
{
$recording = $this->recordings->store('echte audiodaten', 'audio/webm');
$otherAgent = $this->agents->register('OPs', 'ops')['agent'];
$response = $this->controller->download(
Request::create('GET', '/x'),
['id' => (string) $recording['id']],
$otherAgent
);
self::assertSame(200, $response->status);
self::assertSame('echte audiodaten', $response->body());
}
public function testDownloadReturns404ForUnknownRecording(): void
{
$response = $this->controller->download(
Request::create('GET', '/x'),
['id' => '999'],
$this->voiceBot()
);
self::assertSame(404, $response->status);
}
public function testMarkProcessedRejectsUnsupportedStatus(): void
{
$recording = $this->recordings->store('a', 'audio/webm');
$response = $this->controller->markProcessed(
Request::create('PATCH', '/x', body: ['status' => 'archived']),
['id' => (string) $recording['id']],
$this->voiceBot()
);
self::assertSame(422, $response->status);
}
public function testMarkProcessedReturns404ForUnknownRecording(): void
{
$response = $this->controller->markProcessed(
Request::create('PATCH', '/x', body: ['status' => 'done']),
['id' => '999'],
$this->voiceBot()
);
self::assertSame(404, $response->status);
}
public function testMarkProcessedMarksRecordingDone(): void
{
$recording = $this->recordings->store('a', 'audio/webm');
$response = $this->controller->markProcessed(
Request::create('PATCH', '/x', body: ['status' => 'done']),
['id' => (string) $recording['id']],
$this->voiceBot()
);
self::assertSame(200, $response->status);
self::assertSame('done', $response->body()['recording']['status']);
}
}