shouldFail) { throw new RuntimeException('Codeberg-API nicht erreichbar.'); } return []; } public function repoPermissions(string $owner, string $repo): array { return ['push' => true]; } public function listWebhooks(string $owner, string $repo): array { return []; } public function countOpenPullRequests(string $owner, string $repo): int { return 0; } } 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, new CodebergSyncService( new StubCodebergClient(), $this->repository, new RepoConfigRepository(Database::inMemory()), new KpiRepository(Database::inMemory()) ) ); } 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'] ); } public function testResyncRemovesOrphanedTicketOfConfiguredRepoAndReturnsNewBoardState(): void { // 'fds2610/monitor' ist eines der per Migration seedenden Default-Projekte (siehe // 003_repo_configs.sql) - der StubCodebergClient liefert dafuer keine Issues, das // Ticket ist also eine Karteileiche im Sinne von Issue #51. $this->repository->create([ 'title' => 'Karteileiche', 'repo' => 'fds2610/monitor', 'external_ref' => '1', 'source' => 'codeberg', ]); $response = $this->controller->resync(Request::create('POST', '/api/v1/tickets/resync')); self::assertSame(200, $response->status); self::assertSame([], $response->body()['tickets']); } public function testResyncReturns502WhenCodebergUnreachable(): void { $repository = new TicketRepository(Database::inMemory()); $controller = new TicketController( $repository, new CodebergSyncService( new StubCodebergClient(shouldFail: true), $repository, new RepoConfigRepository(Database::inMemory()), new KpiRepository(Database::inMemory()) ) ); $response = $controller->resync(Request::create('POST', '/api/v1/tickets/resync')); self::assertSame(502, $response->status); } }