From 21c331cd06a9aaf4803c0467e915be58347ac37b Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 22 Apr 2026 15:37:26 +0200 Subject: [PATCH] test(architecture): lock down two routing/filter silent-failure modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes two bugs that recently cost debugging time as architecture tests so they cannot recur. PageTemplateNamingTest MintyPHP's Router splits page filenames on parens. `view(default).phtml` → view="view", template="default". A nested group like `view($id)(none).phtml` produces an empty view name, resolves to the wrong file, and the response silently becomes an empty body. URL parameters belong only in the .php action filename. This test flags any .phtml with more than one paren group. FilterSchemaConsistencyTest Every query key declared in a list's filter-schema.php must also appear in the toolbar section (standard pagination/sort keys exempted). If a filter param is not in the toolbar, Grid.js's URL-sync drops it on every refetch — the filter works on initial page load and breaks after any user interaction. This test caught one dormant violation (system-audit's target_type), which is now declared as a hidden toolbar field for future use. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../audit/system-audit/filter-schema.php | 8 ++ .../FilterSchemaConsistencyTest.php | 110 ++++++++++++++++++ tests/Architecture/PageTemplateNamingTest.php | 86 ++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 tests/Architecture/FilterSchemaConsistencyTest.php create mode 100644 tests/Architecture/PageTemplateNamingTest.php diff --git a/modules/audit/pages/audit/system-audit/filter-schema.php b/modules/audit/pages/audit/system-audit/filter-schema.php index 7d8bfe8..416bda1 100644 --- a/modules/audit/pages/audit/system-audit/filter-schema.php +++ b/modules/audit/pages/audit/system-audit/filter-schema.php @@ -46,6 +46,14 @@ return gridFilterSchema([ 'dir' => ['type' => 'dir', 'default' => 'desc'], ], 'toolbar' => [ + [ + // Dormant capability — kept in toolbar as hidden so Grid.js preserves + // the param across refetches if/when a UI is added. + 'key' => 'target_type', + 'type' => 'hidden', + 'input_id' => 'system-audit-target-type-filter', + 'default' => '', + ], [ 'key' => 'search', 'type' => 'text', diff --git a/tests/Architecture/FilterSchemaConsistencyTest.php b/tests/Architecture/FilterSchemaConsistencyTest.php new file mode 100644 index 0000000..cfc8268 --- /dev/null +++ b/tests/Architecture/FilterSchemaConsistencyTest.php @@ -0,0 +1,110 @@ +projectRootPath(); + $schemaFiles = []; + $this->collectFilterSchemas($root . '/pages', $schemaFiles); + $this->collectFilterSchemas($root . '/modules', $schemaFiles); + + $this->assertNotEmpty($schemaFiles, 'No filter-schema.php files found — regex stale?'); + + $violations = []; + + foreach ($schemaFiles as $schemaFile) { + $schema = @include $schemaFile; + if (!is_array($schema)) { + $violations[] = sprintf('%s — filter-schema.php did not return an array', $this->relative($schemaFile, $root)); + continue; + } + + $queryKeys = array_keys(is_array($schema['query'] ?? null) ? $schema['query'] : []); + $toolbarFields = is_array($schema['toolbar'] ?? null) ? $schema['toolbar'] : []; + + // Schemas without a toolbar are for non-Grid endpoints (e.g. global + // search data endpoint consumed by a custom JS dialog). Skip those — + // they never participate in Grid.js URL sync. + if ($toolbarFields === []) { + continue; + } + + $toolbarKeys = []; + foreach ($toolbarFields as $field) { + if (!is_array($field)) { + continue; + } + $key = trim((string) ($field['key'] ?? '')); + if ($key !== '') { + $toolbarKeys[] = $key; + } + } + + $missing = array_diff($queryKeys, $toolbarKeys, self::FRAMEWORK_KEYS); + if ($missing) { + $violations[] = sprintf( + "%s — query keys missing from toolbar: %s.\n Add them to the 'toolbar' section (type 'hidden' is fine) so Grid.js forwards them on refetch.", + $this->relative($schemaFile, $root), + implode(', ', $missing) + ); + } + } + + $this->assertSame( + [], + $violations, + "Filter-schema consistency violations:\n" . implode("\n\n", $violations) + ); + } + + /** + * @param list &$out + */ + private function collectFilterSchemas(string $base, array &$out): void + { + if (!is_dir($base)) { + return; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)); + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isFile() && $file->getFilename() === 'filter-schema.php') { + $out[] = $file->getPathname(); + } + } + } + + private function relative(string $path, string $root): string + { + return str_replace($root . '/', '', $path); + } +} diff --git a/tests/Architecture/PageTemplateNamingTest.php b/tests/Architecture/PageTemplateNamingTest.php new file mode 100644 index 0000000..3c15e79 --- /dev/null +++ b/tests/Architecture/PageTemplateNamingTest.php @@ -0,0 +1,86 @@ +projectRootPath(); + $scanRoots = [$root . '/pages']; + $modulesDir = $root . '/modules'; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) ?: [] as $moduleEntry) { + if ($moduleEntry === '.' || $moduleEntry === '..') { + continue; + } + $modulePages = $modulesDir . '/' . $moduleEntry . '/pages'; + if (is_dir($modulePages)) { + $scanRoots[] = $modulePages; + } + } + } + + $violations = []; + + foreach ($scanRoots as $scanRoot) { + if (!is_dir($scanRoot)) { + continue; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($scanRoot, \FilesystemIterator::SKIP_DOTS)); + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'phtml') { + continue; + } + $name = $file->getFilename(); + $openCount = substr_count($name, '('); + $closeCount = substr_count($name, ')'); + $relativePath = str_replace($root . '/', '', $file->getPathname()); + + if ($openCount !== $closeCount) { + $violations[] = sprintf('%s — mismatched parens', $relativePath); + continue; + } + + // Partials (`_form.phtml`, shared includes) have zero paren groups — skip them. + if ($openCount === 0) { + continue; + } + + // Routed views must have exactly one paren group: `(