order_item -> selections -> modifiers) sur des * jeux de lignes scriptes. Les requetes detail utilisent IN (...) sans parametre lie * (ids casts en entier dans le repo) : on desambiguise donc uniquement sur le texte * SQL. Aucune ecriture (l'operation est lecture seule, RG-5 de 5.1). */ final class FakeKdsDatabase implements DatabaseInterface { /** @var list> commandes paid renvoyees par la file. */ public array $orders = []; /** @var list> lignes order_item (tous order_id confondus). */ public array $items = []; /** @var list> lignes order_item_selection. */ public array $selections = []; /** @var list> lignes order_item_modifier (avec ingredient_name). */ public array $modifiers = []; public function fetch(string $sql, array $params = []): ?array { return null; } public function fetchAll(string $sql, array $params = []): array { if (str_contains($sql, "WHERE status = 'paid'")) { return $this->orders; } if (str_contains($sql, 'FROM order_item WHERE order_id IN')) { return $this->items; } if (str_contains($sql, 'FROM order_item_selection')) { return $this->selections; } if (str_contains($sql, 'FROM order_item_modifier')) { return $this->modifiers; } return []; } public function execute(string $sql, array $params = []): int { return 0; } public function transaction(callable $fn): void { $fn($this); } } /** * Couvre la derivation de la bande SLA (slaBand, RG-4 de 5.1 / Note 6 : vert < 5 min, * ambre 5-10 min, rouge > 10 min, calcul depuis now - paid_at avec horloge injectee) * et l'assemblage du payload enrichi (paidQueueWithDetail : items imbriquant * selections + modifiers, tri preserve, id technique non expose). */ final class OrderQueryRepositoryTest extends TestCase { private const NOW = 1_700_000_000; // epoch de reference fixe (deterministe). /** paid_at calibre a $secondsAgo secondes avant NOW. */ private function paidAtSecondsAgo(int $secondsAgo): string { return date('Y-m-d H:i:s', self::NOW - $secondsAgo); } public function testSlaBandFreshBelowFiveMinutes(): void { $repo = new OrderQueryRepository(new FakeKdsDatabase()); // 4 min ecoulees -> sous le seuil ambre (300 s) -> vert. self::assertSame('fresh', $repo->slaBand($this->paidAtSecondsAgo(240), self::NOW)); } public function testSlaBandWarnBetweenFiveAndTenMinutes(): void { $repo = new OrderQueryRepository(new FakeKdsDatabase()); // 7 min ecoulees -> >= 300 s et < 600 s -> ambre. self::assertSame('warn', $repo->slaBand($this->paidAtSecondsAgo(420), self::NOW)); } public function testSlaBandLateBeyondTenMinutes(): void { $repo = new OrderQueryRepository(new FakeKdsDatabase()); // 12 min ecoulees -> >= 600 s (seuil cible) -> rouge. self::assertSame('late', $repo->slaBand($this->paidAtSecondsAgo(720), self::NOW)); } public function testSlaBandFallsBackToFreshOnEmptyPaidAt(): void { $repo = new OrderQueryRepository(new FakeKdsDatabase()); // Donnee absente : pas d'alerte sur une valeur manquante. self::assertSame('fresh', $repo->slaBand('', self::NOW)); } public function testPaidQueueWithDetailNestsItemsSelectionsAndModifiers(): void { $db = new FakeKdsDatabase(); $db->orders = [ ['id' => 7, 'order_number' => 'K7', 'source' => 'kiosk', 'service_mode' => 'dine_in', 'service_tag' => '3', 'total_ttc_cents' => 990, 'paid_at' => $this->paidAtSecondsAgo(120)], ]; $db->items = [ ['id' => 50, 'order_id' => 7, 'item_type' => 'menu', 'format' => 'maxi', 'label_snapshot' => 'Menu Le 280', 'quantity' => 1], ]; $db->selections = [ ['order_item_id' => 50, 'label_snapshot' => 'Coca 50cl'], ['order_item_id' => 50, 'label_snapshot' => 'Grande Frite'], ]; $db->modifiers = [ ['order_item_id' => 50, 'action' => 'remove', 'ingredient_name' => 'oignon'], ['order_item_id' => 50, 'action' => 'add', 'ingredient_name' => 'bacon'], ]; $queue = (new OrderQueryRepository($db))->paidQueueWithDetail(['kiosk', 'counter', 'drive'], self::NOW); self::assertCount(1, $queue); $order = $queue[0]; // L'id technique (cle de jointure) n'est pas expose a la vue. self::assertArrayNotHasKey('id', $order); self::assertSame('K7', $order['order_number']); self::assertSame('fresh', $order['sla_band']); // 2 min ecoulees. self::assertCount(1, $order['items']); $item = $order['items'][0]; self::assertSame('Menu Le 280', $item['label_snapshot']); self::assertSame('maxi', $item['format']); self::assertSame(['Coca 50cl', 'Grande Frite'], array_column($item['selections'], 'label_snapshot')); self::assertSame(['oignon', 'bacon'], array_column($item['modifiers'], 'ingredient_name')); self::assertSame(['remove', 'add'], array_column($item['modifiers'], 'action')); } public function testPaidQueueWithDetailEmptyWhenNoVisibleSource(): void { // Aucune source visible -> file vide (coherent avec paidQueue). $queue = (new OrderQueryRepository(new FakeKdsDatabase()))->paidQueueWithDetail([], self::NOW); self::assertSame([], $queue); } public function testPaidQueueWithDetailHandlesOrderWithoutItems(): void { // Commande sans ligne (cas degrade) : items vide, pas d'erreur. $db = new FakeKdsDatabase(); $db->orders = [ ['id' => 9, 'order_number' => 'K9', 'source' => 'kiosk', 'service_mode' => 'takeaway', 'service_tag' => null, 'total_ttc_cents' => 500, 'paid_at' => $this->paidAtSecondsAgo(60)], ]; $queue = (new OrderQueryRepository($db))->paidQueueWithDetail(['kiosk'], self::NOW); self::assertCount(1, $queue); self::assertSame([], $queue[0]['items']); } }