'sqlite::memory:', 'options' => [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ]); $database->pdo()->exec('PRAGMA foreign_keys = ON'); } return $database; } function check(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } $db = database(); foreach ([ '20260522_000001_create_boards_table.php', '20260522_000002_create_board_columns_table.php', '20260522_000003_create_swim_lanes_table.php', '20260522_000004_create_cards_table.php', '20260615_000003_add_cell_entered_at_to_cards.php', '20260917_000001_create_card_movements_table.php', ] as $migrationFile) { (require __DIR__ . '/../database/migrations/' . $migrationFile)->up($db); } $db->execute("INSERT INTO boards (id, name, slug) VALUES (1, 'Main', 'main'), (2, 'Other', 'other')"); $db->execute("INSERT INTO board_columns (id, board_id, name, position) VALUES (1, 1, 'Queued', 0), (2, 1, 'Printing', 1), (3, 2, 'Other column', 0)"); $db->execute("INSERT INTO swim_lanes (id, board_id, name, position) VALUES (1, 1, 'Standard', 0), (2, 1, 'Urgent', 1), (3, 2, 'Other row', 0)"); $db->execute("INSERT INTO cards (id, board_id, column_id, swim_lane_id, position, cell_entered_at) VALUES (1, 1, 1, 1, 0, '2026-01-01 00:00:00')"); putenv('APP_DEBUG=0'); date_default_timezone_set('America/New_York'); $_SESSION['auth_user'] = ['preferred_username' => 'test-user']; $controller = new CardsController(); $move = static function (int $columnId, int $laneId, int $position = 0) use ($controller): Core\Response { return $controller->move(new Request([], [ 'column_id' => (string) $columnId, 'swim_lane_id' => (string) $laneId, 'position' => (string) $position, ], ['REQUEST_METHOD' => 'POST']), 1); }; check($move(2, 2)->status() === 200, 'Move should succeed.'); $card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1'); check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 2, 'Card destination was not saved.'); $history = $db->first('SELECT * FROM card_movements WHERE card_id = 1'); check($history !== null, 'Movement was not recorded.'); check($history['from_column_name'] === 'Queued' && $history['to_column_name'] === 'Printing', 'Column history is incorrect.'); check($history['from_swim_lane_name'] === 'Standard' && $history['to_swim_lane_name'] === 'Urgent', 'Row history is incorrect.'); check($history['moved_by'] === 'test-user', 'Move user was not recorded.'); check(abs(strtotime($history['moved_at'] . ' UTC') - time()) < 10, 'Movement time was not saved in UTC.'); $response = $controller->history(1); $recorded = json_decode($response->content(), true, flags: JSON_THROW_ON_ERROR)['movements'][0]; check($recorded['to_column_name'] === 'Printing' && $recorded['to_swim_lane_name'] === 'Urgent', 'History API omitted the destination.'); check($move(2, 2, 1)->status() === 200, 'Reordering should succeed.'); check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 1, 'Reordering added a movement.'); check($move(2, 1)->status() === 200, 'Row-only move should succeed.'); check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Row-only move was not recorded.'); check($move(3, 1)->status() === 422, 'Cross-board column should be rejected.'); check($move(2, 3)->status() === 422, 'Cross-board row should be rejected.'); $card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1'); check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 1, 'Rejected move changed the card.'); check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Rejected move changed history.'); $db->execute("CREATE TRIGGER reject_movement BEFORE INSERT ON card_movements BEGIN SELECT RAISE(ABORT, 'forced history failure'); END"); $logFile = tempnam(sys_get_temp_dir(), 'card_move_'); $previousLog = ini_get('error_log'); ini_set('error_log', $logFile); try { check($move(1, 1)->status() === 500, 'History failure should reject the move.'); $card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1'); check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 1, 'Failed history write did not roll back the move.'); check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Failed move changed history.'); } finally { ini_set('error_log', $previousLog ?: ''); unlink($logFile); $db->execute('DROP TRIGGER reject_movement'); } echo "PASS: card moves record column and row history\n";