,string}> */ public static function dateCases(): array { // [from, to, expectedFrom, expectedTo, expectedErrorKeys, label] return [ ['', '', '', '', [], 'no filters → no errors'], ['2026-01-31', '', '2026-01-31', '', [], 'from-only valid'], ['', '2026-12-31', '', '2026-12-31', [], 'to-only valid'], ['2026-01-01', '2026-12-31', '2026-01-01', '2026-12-31', [], 'both valid'], ['2024/01/01', '', '', '', ['from_date'], 'slashes → from dropped, error reported'], ['', '2024/01/01', '', '', ['to_date'], 'slashes → to dropped, error reported'], ['2024/01/01', '2024/12/31', '', '', ['from_date', 'to_date'], 'both garbage → both dropped, both errors'], ['2026-01-31', 'tomorrow', '2026-01-31', '', ['to_date'], 'one-side invalid → only that side errors, the other passes'], ['2026-13-01', '', '', '', ['from_date'], 'impossible month → rejected'], ['2026-02-30', '', '', '', ['from_date'], 'impossible day for month → rejected (strict round-trip)'], ['2026-1-1', '', '', '', ['from_date'], 'unpadded → rejected (strict round-trip)'], [' 2026-01-01', '', '', '', ['from_date'], 'leading space → rejected'], ['2026-01-01 ', '', '', '', ['from_date'], 'trailing space → rejected'], ["'; DROP TABLE audit_log; --", '', '', '', ['from_date'], 'injection-shaped string → rejected'], ]; } /** @param list $expectedErrorKeys */ #[DataProvider('dateCases')] public function testValidateDateFilters( string $from, string $to, string $expectedFrom, string $expectedTo, array $expectedErrorKeys, string $label, ): void { $out = AuditController::validateDateFilters($from, $to); $this->assertSame($expectedFrom, $out['from'], "{$label}: from"); $this->assertSame($expectedTo, $out['to'], "{$label}: to"); $this->assertSame( $expectedErrorKeys, array_keys($out['errors']), "{$label}: error keys", ); // Error messages are non-empty when present (keeps the contract for // the view, which renders {{ dateErrors.from_date }} verbatim). foreach ($out['errors'] as $key => $msg) { $this->assertNotSame('', $msg, "{$label}: error message for {$key} is non-empty"); } } public function testValidatorPreservesValidValuesVerbatim(): void { // Boundary that the repo concatenates onto: a leap day. Confirms // we don't normalise or reformat — the repo gets exactly what the // user typed (after gating). $out = AuditController::validateDateFilters('2024-02-29', '2024-02-29'); $this->assertSame('2024-02-29', $out['from']); $this->assertSame('2024-02-29', $out['to']); $this->assertSame([], $out['errors']); } public function testValidatorRejectsNonLeapFebTwentyNine(): void { // 2025 is not a leap year. createFromFormat is lenient and silently // rolls over to 2025-03-01; the round-trip equality check we use // is what catches it. $out = AuditController::validateDateFilters('2025-02-29', ''); $this->assertSame('', $out['from']); $this->assertArrayHasKey('from_date', $out['errors']); } }