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>
199 lines
6.5 KiB
PHP
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']);
|
|
}
|
|
}
|