connection = $this->createMock(Connection::class); $this->clock = $this->createMock(Clock::class); $this->command = new ArchiveAuditLogsCommand( $this->connection, $this->clock, ); $this->commandTester = new CommandTester($this->command); } public function testCommandNameIsCorrect(): void { $this->assertSame('app:audit:archive', $this->command->getName()); } public function testCommandDescription(): void { $this->assertSame( 'Archive audit log entries older than 5 years', $this->command->getDescription(), ); } public function testNoEntriesToArchiveReturnsSuccess(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); $this->connection->method('fetchOne') ->willReturnOnConsecutiveCalls(0); // COUNT returns 0 $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('No entries to archive', $output); $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); } public function testDryRunDoesNotCallArchiveFunction(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); $this->connection->expects($this->once()) ->method('fetchOne') ->willReturn(100); // 100 entries to archive // archive_audit_entries should NOT be called in dry-run mode $this->connection->expects($this->never()) ->method('executeStatement'); $this->commandTester->execute(['--dry-run' => true]); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('DRY RUN', $output); $this->assertStringContainsString('Would archive 100 entries', $output); $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); } public function testArchivesBatchesUntilComplete(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); // First call: COUNT returns 150 // Subsequent calls: archive_audit_entries returns batch counts $this->connection->method('fetchOne') ->willReturnOnConsecutiveCalls( 150, // COUNT query 100, // First batch (full) 50, // Second batch (partial, stops) ); $this->commandTester->execute(['--batch-size' => '100']); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Successfully archived 150', $output); $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); } public function testCustomRetentionYears(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); $this->connection->method('fetchOne')->willReturn(0); $this->commandTester->execute(['--retention-years' => '3']); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('(3 years retention)', $output); } public function testCustomBatchSize(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); // Return 500 entries to archive, then archive in 500-entry batches $this->connection->method('fetchOne') ->willReturnOnConsecutiveCalls( 500, // COUNT 500, // First batch (equal to batch size) 0, // Second batch (none left) ); $this->commandTester->execute(['--batch-size' => '500']); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Successfully archived 500', $output); } public function testShowsProgressBar(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); $this->connection->method('fetchOne') ->willReturnOnConsecutiveCalls( 50, // COUNT 50, // First batch ); $this->commandTester->execute([]); // Progress bar output includes percentage $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Successfully archived 50', $output); } public function testCalculatesCutoffDateCorrectly(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); $capturedCutoff = null; $this->connection->method('fetchOne') ->willReturnCallback(static function (string $sql, array $params) use (&$capturedCutoff) { if (str_contains($sql, 'COUNT')) { $capturedCutoff = $params['cutoff']; return 0; } return 0; }); $this->commandTester->execute(['--retention-years' => '5']); // Cutoff should be 5 years before now (2021-02-03) $this->assertNotNull($capturedCutoff); $this->assertStringContainsString('2021-02-03', $capturedCutoff); } public function testZeroBatchSizeReturnsFailure(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); $this->commandTester->execute(['--batch-size' => '0']); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Batch size must be a positive integer', $output); $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); } public function testNegativeBatchSizeReturnsFailure(): void { $now = new DateTimeImmutable('2026-02-03 10:00:00'); $this->clock->method('now')->willReturn($now); $this->commandTester->execute(['--batch-size' => '-5']); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Batch size must be a positive integer', $output); $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); } public function testZeroRetentionYearsReturnsFailure(): void { $this->commandTester->execute(['--retention-years' => '0']); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Retention years must be a positive integer', $output); $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); } public function testNegativeRetentionYearsReturnsFailure(): void { $this->commandTester->execute(['--retention-years' => '-5']); $output = $this->commandTester->getDisplay(); $this->assertStringContainsString('Retention years must be a positive integer', $output); $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); } }