forked from fa/breadcrumb-the-shire
Split module structure contracts
This commit is contained in:
10
phpunit.xml
10
phpunit.xml
@@ -19,8 +19,11 @@
|
||||
<file>tests/Architecture/DatabaseUpdatesContractTest.php</file>
|
||||
<file>tests/Architecture/FrontendTelemetryContractTest.php</file>
|
||||
<file>tests/Architecture/I18nKeyCompletenessContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleBoundaryContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleManifestContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleRegistryContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleStructureContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleProviderContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleUiContractTest.php</file>
|
||||
<file>tests/Architecture/RepositoryInterfaceContractTest.php</file>
|
||||
<file>tests/Architecture/StatusTaxonomyContractTest.php</file>
|
||||
</testsuite>
|
||||
@@ -58,7 +61,10 @@
|
||||
</testsuite>
|
||||
<testsuite name="ArchitectureModules">
|
||||
<file>tests/Architecture/ModuleRegistryContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleStructureContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleBoundaryContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleManifestContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleProviderContractTest.php</file>
|
||||
<file>tests/Architecture/ModuleUiContractTest.php</file>
|
||||
<file>tests/Architecture/NoAddressBookHardcodingTest.php</file>
|
||||
<file>tests/Architecture/NoBookmarksHardcodingTest.php</file>
|
||||
</testsuite>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use MintyPHP\App\Module\ModuleManifest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
abstract class AbstractModuleStructureContractTestCase extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/** @var list<array{id: string, path: string, manifest: ModuleManifest, raw: array<string, mixed>}> */
|
||||
protected static array $modules = [];
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
$modulesDir = realpath(__DIR__ . '/../../modules');
|
||||
if ($modulesDir === false || !is_dir($modulesDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = scandir($modulesDir);
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$modules = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifestFile = $modulesDir . '/' . $entry . '/module.php';
|
||||
if (!is_file($manifestFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = require $manifestFile;
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry);
|
||||
self::$modules[] = [
|
||||
'id' => $entry,
|
||||
'path' => $modulesDir . '/' . $entry,
|
||||
'manifest' => $manifest,
|
||||
'raw' => $raw,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
83
tests/Architecture/ModuleBoundaryContractTest.php
Normal file
83
tests/Architecture/ModuleBoundaryContractTest.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
final class ModuleBoundaryContractTest extends AbstractModuleStructureContractTestCase
|
||||
{
|
||||
public function testModuleClassesUseModuleNamespace(): void
|
||||
{
|
||||
$coreNamespacePrefixes = [
|
||||
'MintyPHP\\Service\\',
|
||||
'MintyPHP\\Repository\\',
|
||||
'MintyPHP\\Support\\',
|
||||
'MintyPHP\\Http\\',
|
||||
'MintyPHP\\App\\Container\\',
|
||||
];
|
||||
|
||||
foreach (self::$modules as $module) {
|
||||
$libDir = $module['path'] . '/lib';
|
||||
if (!is_dir($libDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) {
|
||||
$namespace = $match[1];
|
||||
foreach ($coreNamespacePrefixes as $corePrefix) {
|
||||
self::assertFalse(
|
||||
str_starts_with($namespace . '\\', $corePrefix),
|
||||
"Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. "
|
||||
. "Module classes must use MintyPHP\\Module\\* namespace."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testModulePagesDoNotImportCoreAbilityConstants(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$pagesDir = $module['path'] . '/pages';
|
||||
if (!is_dir($pagesDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($pagesDir)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::assertStringNotContainsString(
|
||||
'OperationsAuthorizationPolicy::ABILITY_',
|
||||
$content,
|
||||
"Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. "
|
||||
. "Use the module's own AuthorizationPolicy instead."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
tests/Architecture/ModuleManifestContractTest.php
Normal file
142
tests/Architecture/ModuleManifestContractTest.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
final class ModuleManifestContractTest extends AbstractModuleStructureContractTestCase
|
||||
{
|
||||
public function testAtLeastOneModuleExists(): void
|
||||
{
|
||||
self::assertNotEmpty(self::$modules, 'Expected at least one module under modules/');
|
||||
}
|
||||
|
||||
public function testEveryModuleHasManifest(): void
|
||||
{
|
||||
$modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||
if (!is_dir($modulesDir)) {
|
||||
self::markTestSkipped('No modules/ directory');
|
||||
}
|
||||
|
||||
$entries = scandir($modulesDir) ?: [];
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
||||
continue;
|
||||
}
|
||||
self::assertFileExists(
|
||||
$modulesDir . '/' . $entry . '/module.php',
|
||||
"Module directory '{$entry}' is missing module.php manifest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testManifestIdMatchesDirectoryName(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
self::assertSame(
|
||||
$module['id'],
|
||||
$module['manifest']->id,
|
||||
"Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testManifestHasVersion(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^\d+\.\d+\.\d+$/',
|
||||
$module['manifest']->version,
|
||||
"Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testManifestHasAllRequiredKeys(): void
|
||||
{
|
||||
$requiredKeys = [
|
||||
'id', 'version', 'enabled_by_default', 'load_order',
|
||||
'routes', 'public_paths', 'container_registrars',
|
||||
'ui_slots', 'search_resources', 'asset_groups',
|
||||
'scheduler_jobs', 'layout_context_providers',
|
||||
'session_providers', 'permissions',
|
||||
];
|
||||
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($requiredKeys as $key) {
|
||||
self::assertArrayHasKey(
|
||||
$key,
|
||||
$module['raw'],
|
||||
"Module '{$module['id']}' manifest is missing required key '{$key}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRequiresFieldContainsOnlyValidModuleIds(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->requires as $index => $requiredId) {
|
||||
self::assertIsString($requiredId);
|
||||
self::assertNotEmpty(
|
||||
trim($requiredId),
|
||||
"Module '{$module['id']}' requires[{$index}] must be a non-empty string"
|
||||
);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^[a-z][a-z0-9_-]*$/',
|
||||
$requiredId,
|
||||
"Module '{$module['id']}' requires[{$index}] has invalid module id format: '{$requiredId}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testModuleHasLibDirectory(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
self::assertDirectoryExists(
|
||||
$module['path'] . '/lib',
|
||||
"Module '{$module['id']}' must have a lib/ directory for PHP classes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRoutesHavePathAndTarget(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->routes as $idx => $route) {
|
||||
self::assertArrayHasKey(
|
||||
'path',
|
||||
$route,
|
||||
"Module '{$module['id']}' route[{$idx}] is missing 'path'"
|
||||
);
|
||||
self::assertArrayHasKey(
|
||||
'target',
|
||||
$route,
|
||||
"Module '{$module['id']}' route[{$idx}] is missing 'target'"
|
||||
);
|
||||
self::assertNotEmpty(
|
||||
trim((string) $route['path']),
|
||||
"Module '{$module['id']}' route[{$idx}] has empty 'path'"
|
||||
);
|
||||
self::assertNotEmpty(
|
||||
trim((string) $route['target']),
|
||||
"Module '{$module['id']}' route[{$idx}] has empty 'target'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testModulesWithRoutesHavePagesDirectory(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
if (empty($module['manifest']->routes)) {
|
||||
continue;
|
||||
}
|
||||
self::assertDirectoryExists(
|
||||
$module['path'] . '/pages',
|
||||
"Module '{$module['id']}' declares routes but has no pages/ directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
141
tests/Architecture/ModuleProviderContractTest.php
Normal file
141
tests/Architecture/ModuleProviderContractTest.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use MintyPHP\App\Module\Contracts\EventListener;
|
||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
|
||||
final class ModuleProviderContractTest extends AbstractModuleStructureContractTestCase
|
||||
{
|
||||
public function testContainerRegistrarClassesExist(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->containerRegistrars as $registrar) {
|
||||
self::assertTrue(
|
||||
class_exists($registrar),
|
||||
"Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testSearchProviderClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->searchResources as $providerClass) {
|
||||
self::assertTrue(
|
||||
class_exists($providerClass),
|
||||
"Module '{$module['id']}' search provider '{$providerClass}' class not found"
|
||||
);
|
||||
$provider = new $providerClass();
|
||||
self::assertInstanceOf(
|
||||
SearchResourceProvider::class,
|
||||
$provider,
|
||||
"Module '{$module['id']}' search provider must implement SearchResourceProvider"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testLayoutContextProviderClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->layoutContextProviders as $providerClass) {
|
||||
self::assertTrue(
|
||||
class_exists($providerClass),
|
||||
"Module '{$module['id']}' layout context provider '{$providerClass}' class not found"
|
||||
);
|
||||
$provider = new $providerClass();
|
||||
self::assertInstanceOf(
|
||||
LayoutContextProvider::class,
|
||||
$provider,
|
||||
"Module '{$module['id']}' layout context provider must implement LayoutContextProvider"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testSessionProviderClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->sessionProviders as $providerClass) {
|
||||
self::assertTrue(
|
||||
class_exists($providerClass),
|
||||
"Module '{$module['id']}' session provider '{$providerClass}' class not found"
|
||||
);
|
||||
$provider = new $providerClass();
|
||||
self::assertInstanceOf(
|
||||
SessionProvider::class,
|
||||
$provider,
|
||||
"Module '{$module['id']}' session provider must implement SessionProvider"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testAuthorizationPolicyClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->authorizationPolicies as $policyClass) {
|
||||
self::assertTrue(
|
||||
class_exists($policyClass),
|
||||
"Module '{$module['id']}' authorization policy '{$policyClass}' class not found"
|
||||
);
|
||||
self::assertTrue(
|
||||
is_subclass_of($policyClass, AuthorizationPolicyInterface::class)
|
||||
|| in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true),
|
||||
"Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testEventListenerClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->eventListeners as $eventName => $handlers) {
|
||||
foreach ($handlers as $index => $handler) {
|
||||
$class = $handler['class'];
|
||||
self::assertTrue(
|
||||
class_exists($class),
|
||||
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' not found"
|
||||
);
|
||||
self::assertTrue(
|
||||
is_subclass_of($class, EventListener::class)
|
||||
|| in_array(EventListener::class, class_implements($class) ?: [], true),
|
||||
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' "
|
||||
. "must implement EventListener interface"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testDeactivationHandlerClassImplementsContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$handler = $module['manifest']->deactivationHandler;
|
||||
if ($handler === null) {
|
||||
continue;
|
||||
}
|
||||
self::assertTrue(
|
||||
class_exists($handler),
|
||||
"Module '{$module['id']}' deactivation_handler class '{$handler}' not found"
|
||||
);
|
||||
self::assertTrue(
|
||||
is_subclass_of($handler, ModuleDeactivationHandler::class)
|
||||
|| in_array(ModuleDeactivationHandler::class, class_implements($handler) ?: [], true),
|
||||
"Module '{$module['id']}' deactivation_handler class '{$handler}' "
|
||||
. "must implement ModuleDeactivationHandler interface"
|
||||
);
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
@@ -1,708 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use MintyPHP\App\Module\Contracts\EventListener;
|
||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||
use MintyPHP\App\Module\ModuleManifest;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Architecture test: enforces structural conventions for ALL modules under modules/.
|
||||
*
|
||||
* Every module directory must pass these checks. This ensures consistency
|
||||
* when new modules are added and prevents structural drift.
|
||||
*/
|
||||
final class ModuleStructureContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/** @var list<array{id: string, path: string, manifest: ModuleManifest, raw: array<string, mixed>}> */
|
||||
private static array $modules = [];
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
$modulesDir = realpath(__DIR__ . '/../../modules');
|
||||
if ($modulesDir === false || !is_dir($modulesDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = scandir($modulesDir);
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifestFile = $modulesDir . '/' . $entry . '/module.php';
|
||||
if (!is_file($manifestFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = require $manifestFile;
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry);
|
||||
self::$modules[] = [
|
||||
'id' => $entry,
|
||||
'path' => $modulesDir . '/' . $entry,
|
||||
'manifest' => $manifest,
|
||||
'raw' => $raw,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manifest structure ──────────────────────────────────────────
|
||||
|
||||
public function testAtLeastOneModuleExists(): void
|
||||
{
|
||||
self::assertNotEmpty(self::$modules, 'Expected at least one module under modules/');
|
||||
}
|
||||
|
||||
public function testEveryModuleHasManifest(): void
|
||||
{
|
||||
$modulesDir = $this->projectRootPath() . '/modules';
|
||||
if (!is_dir($modulesDir)) {
|
||||
self::markTestSkipped('No modules/ directory');
|
||||
}
|
||||
|
||||
$entries = scandir($modulesDir) ?: [];
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
||||
continue;
|
||||
}
|
||||
self::assertFileExists(
|
||||
$modulesDir . '/' . $entry . '/module.php',
|
||||
"Module directory '{$entry}' is missing module.php manifest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testManifestIdMatchesDirectoryName(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
self::assertSame(
|
||||
$module['id'],
|
||||
$module['manifest']->id,
|
||||
"Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testManifestHasVersion(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^\d+\.\d+\.\d+$/',
|
||||
$module['manifest']->version,
|
||||
"Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testManifestHasAllRequiredKeys(): void
|
||||
{
|
||||
$requiredKeys = [
|
||||
'id', 'version', 'enabled_by_default', 'load_order',
|
||||
'routes', 'public_paths', 'container_registrars',
|
||||
'ui_slots', 'search_resources', 'asset_groups',
|
||||
'scheduler_jobs', 'layout_context_providers',
|
||||
'session_providers', 'permissions',
|
||||
];
|
||||
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($requiredKeys as $key) {
|
||||
self::assertArrayHasKey(
|
||||
$key,
|
||||
$module['raw'],
|
||||
"Module '{$module['id']}' manifest is missing required key '{$key}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRequiresFieldContainsOnlyValidModuleIds(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->requires as $index => $requiredId) {
|
||||
self::assertIsString($requiredId);
|
||||
self::assertNotEmpty(
|
||||
trim($requiredId),
|
||||
"Module '{$module['id']}' requires[{$index}] must be a non-empty string"
|
||||
);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^[a-z][a-z0-9_-]*$/',
|
||||
$requiredId,
|
||||
"Module '{$module['id']}' requires[{$index}] has invalid module id format: '{$requiredId}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── File structure ──────────────────────────────────────────────
|
||||
|
||||
public function testModuleHasLibDirectory(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
self::assertDirectoryExists(
|
||||
$module['path'] . '/lib',
|
||||
"Module '{$module['id']}' must have a lib/ directory for PHP classes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Container registrars ────────────────────────────────────────
|
||||
|
||||
public function testContainerRegistrarClassesExist(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->containerRegistrars as $registrar) {
|
||||
self::assertTrue(
|
||||
class_exists($registrar),
|
||||
"Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider contracts ──────────────────────────────────────────
|
||||
|
||||
public function testSearchProviderClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->searchResources as $providerClass) {
|
||||
self::assertTrue(
|
||||
class_exists($providerClass),
|
||||
"Module '{$module['id']}' search provider '{$providerClass}' class not found"
|
||||
);
|
||||
$provider = new $providerClass();
|
||||
self::assertInstanceOf(
|
||||
SearchResourceProvider::class,
|
||||
$provider,
|
||||
"Module '{$module['id']}' search provider must implement SearchResourceProvider"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testLayoutContextProviderClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->layoutContextProviders as $providerClass) {
|
||||
self::assertTrue(
|
||||
class_exists($providerClass),
|
||||
"Module '{$module['id']}' layout context provider '{$providerClass}' class not found"
|
||||
);
|
||||
$provider = new $providerClass();
|
||||
self::assertInstanceOf(
|
||||
LayoutContextProvider::class,
|
||||
$provider,
|
||||
"Module '{$module['id']}' layout context provider must implement LayoutContextProvider"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testSessionProviderClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->sessionProviders as $providerClass) {
|
||||
self::assertTrue(
|
||||
class_exists($providerClass),
|
||||
"Module '{$module['id']}' session provider '{$providerClass}' class not found"
|
||||
);
|
||||
$provider = new $providerClass();
|
||||
self::assertInstanceOf(
|
||||
SessionProvider::class,
|
||||
$provider,
|
||||
"Module '{$module['id']}' session provider must implement SessionProvider"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Authorization policies ──────────────────────────────────────
|
||||
|
||||
public function testAuthorizationPolicyClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->authorizationPolicies as $policyClass) {
|
||||
self::assertTrue(
|
||||
class_exists($policyClass),
|
||||
"Module '{$module['id']}' authorization policy '{$policyClass}' class not found"
|
||||
);
|
||||
self::assertTrue(
|
||||
is_subclass_of($policyClass, AuthorizationPolicyInterface::class)
|
||||
|| in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true),
|
||||
"Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── UI slot abilities ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every 'permission' value in a module's UI slots must reference an ability
|
||||
* declared as a constant on one of the module's authorization policies.
|
||||
*/
|
||||
public function testUiSlotAbilitiesMatchPolicySupports(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
// Collect all abilities supported by this module's policies
|
||||
$supportedAbilities = [];
|
||||
foreach ($module['manifest']->authorizationPolicies as $policyClass) {
|
||||
if (!class_exists($policyClass)) {
|
||||
continue;
|
||||
}
|
||||
$reflection = new \ReflectionClass($policyClass);
|
||||
foreach ($reflection->getConstants() as $constValue) {
|
||||
if (is_string($constValue)) {
|
||||
$supportedAbilities[] = $constValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($supportedAbilities === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check every permission value from UI slots
|
||||
foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
|
||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||
foreach ($contributionList as $idx => $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
$ability = trim((string) ($contribution['permission'] ?? ''));
|
||||
if ($ability === '') {
|
||||
continue;
|
||||
}
|
||||
self::assertContains(
|
||||
$ability,
|
||||
$supportedAbilities,
|
||||
"Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] permission '{$ability}' "
|
||||
. "is not declared as a constant on any module authorization policy"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Permissions contract ───────────────────────────────────────
|
||||
|
||||
public function testPermissionsUseObjectContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->permissions as $index => $permission) {
|
||||
self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key");
|
||||
self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description");
|
||||
self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty");
|
||||
self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty");
|
||||
self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1");
|
||||
self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scheduler jobs contract ────────────────────────────────────
|
||||
|
||||
public function testSchedulerJobsUseFullContractSchema(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->schedulerJobs as $index => $job) {
|
||||
$required = [
|
||||
'job_key',
|
||||
'handler',
|
||||
'label',
|
||||
'description',
|
||||
'default_enabled',
|
||||
'default_timezone',
|
||||
'default_schedule_type',
|
||||
'default_schedule_interval',
|
||||
'default_schedule_time',
|
||||
'default_schedule_weekdays_csv',
|
||||
'default_catchup_once',
|
||||
'allowed_schedule_types',
|
||||
];
|
||||
|
||||
foreach ($required as $requiredKey) {
|
||||
self::assertArrayHasKey(
|
||||
$requiredKey,
|
||||
$job,
|
||||
"Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'"
|
||||
);
|
||||
}
|
||||
|
||||
self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty");
|
||||
self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty");
|
||||
self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty");
|
||||
self::assertContains(
|
||||
(string) $job['default_schedule_type'],
|
||||
['hourly', 'daily', 'weekly'],
|
||||
"Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid"
|
||||
);
|
||||
|
||||
$allowedTypes = array_values($job['allowed_schedule_types']);
|
||||
self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty");
|
||||
foreach ($allowedTypes as $allowedType) {
|
||||
self::assertContains(
|
||||
(string) $allowedType,
|
||||
['hourly', 'daily', 'weekly'],
|
||||
"Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── UI slots ────────────────────────────────────────────────────
|
||||
|
||||
public function testUiSlotContributionsHaveRequiredKeys(): void
|
||||
{
|
||||
$slotKeyRequirements = [
|
||||
'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
|
||||
'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
|
||||
'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
|
||||
'topbar.right_item' => ['key', 'template'],
|
||||
'layout.body_end_template' => ['key', 'template'],
|
||||
'layout.head_style' => ['key', 'path'],
|
||||
'runtime.component' => ['key', 'script'],
|
||||
];
|
||||
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
|
||||
if (!isset($slotKeyRequirements[$slotName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$required = $slotKeyRequirements[$slotName];
|
||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||
|
||||
foreach ($contributionList as $idx => $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($required as $requiredKey) {
|
||||
self::assertArrayHasKey(
|
||||
$requiredKey,
|
||||
$contribution,
|
||||
"Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testPanelTemplatesExist(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$templateSlots = [
|
||||
'aside.tab_panel' => 'panel_template',
|
||||
'topbar.right_item' => 'template',
|
||||
'layout.body_end_template' => 'template',
|
||||
];
|
||||
|
||||
foreach ($templateSlots as $slotName => $templateKey) {
|
||||
$contributions = $module['manifest']->uiSlots[$slotName] ?? [];
|
||||
if (!is_array($contributions)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($contributions as $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
$template = $contribution[$templateKey] ?? null;
|
||||
if ($template === null) {
|
||||
continue;
|
||||
}
|
||||
// Resolve relative paths against module basePath
|
||||
$templatePath = (string) $template;
|
||||
if (!str_starts_with($templatePath, '/')) {
|
||||
$templatePath = $module['manifest']->basePath . '/' . $templatePath;
|
||||
}
|
||||
self::assertFileExists(
|
||||
$templatePath,
|
||||
"Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$templatePath}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRuntimeComponentPhasesAreValid(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? [];
|
||||
if (!is_array($runtimeComponents)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($runtimeComponents as $index => $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$phase = strtolower(trim((string) ($contribution['phase'] ?? 'late')));
|
||||
self::assertContains(
|
||||
$phase,
|
||||
['early', 'default', 'late'],
|
||||
"Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Routes ──────────────────────────────────────────────────────
|
||||
|
||||
public function testRoutesHavePathAndTarget(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->routes as $idx => $route) {
|
||||
self::assertArrayHasKey(
|
||||
'path',
|
||||
$route,
|
||||
"Module '{$module['id']}' route[{$idx}] is missing 'path'"
|
||||
);
|
||||
self::assertArrayHasKey(
|
||||
'target',
|
||||
$route,
|
||||
"Module '{$module['id']}' route[{$idx}] is missing 'target'"
|
||||
);
|
||||
self::assertNotEmpty(
|
||||
trim((string) $route['path']),
|
||||
"Module '{$module['id']}' route[{$idx}] has empty 'path'"
|
||||
);
|
||||
self::assertNotEmpty(
|
||||
trim((string) $route['target']),
|
||||
"Module '{$module['id']}' route[{$idx}] has empty 'target'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pages directory ─────────────────────────────────────────────
|
||||
|
||||
public function testModulesWithRoutesHavePagesDirectory(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
if (empty($module['manifest']->routes)) {
|
||||
continue;
|
||||
}
|
||||
self::assertDirectoryExists(
|
||||
$module['path'] . '/pages',
|
||||
"Module '{$module['id']}' declares routes but has no pages/ directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Namespace ownership ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All PHP classes under modules/<id>/lib/ must use a MintyPHP\Module\<Name>\
|
||||
* namespace — never a Core namespace like MintyPHP\Service\ or MintyPHP\Repository\.
|
||||
*
|
||||
* This prevents module code from masquerading as core code and ensures
|
||||
* `grep MintyPHP\Module\` reliably finds all module-owned classes.
|
||||
*/
|
||||
public function testModuleClassesUseModuleNamespace(): void
|
||||
{
|
||||
$coreNamespacePrefixes = [
|
||||
'MintyPHP\\Service\\',
|
||||
'MintyPHP\\Repository\\',
|
||||
'MintyPHP\\Support\\',
|
||||
'MintyPHP\\Http\\',
|
||||
'MintyPHP\\App\\Container\\',
|
||||
];
|
||||
|
||||
foreach (self::$modules as $module) {
|
||||
$libDir = $module['path'] . '/lib';
|
||||
if (!is_dir($libDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) {
|
||||
$namespace = $match[1];
|
||||
foreach ($coreNamespacePrefixes as $corePrefix) {
|
||||
self::assertFalse(
|
||||
str_starts_with($namespace . '\\', $corePrefix),
|
||||
"Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. "
|
||||
. "Module classes must use MintyPHP\\Module\\* namespace."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event listener contract ────────────────────────────────────────
|
||||
|
||||
public function testEventListenerClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->eventListeners as $eventName => $handlers) {
|
||||
foreach ($handlers as $index => $handler) {
|
||||
$class = $handler['class'];
|
||||
self::assertTrue(
|
||||
class_exists($class),
|
||||
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' not found"
|
||||
);
|
||||
self::assertTrue(
|
||||
is_subclass_of($class, EventListener::class)
|
||||
|| in_array(EventListener::class, class_implements($class) ?: [], true),
|
||||
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' "
|
||||
. "must implement EventListener interface"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── Deactivation handler contract ──────────────────────────────
|
||||
|
||||
public function testDeactivationHandlerClassImplementsContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$handler = $module['manifest']->deactivationHandler;
|
||||
if ($handler === null) {
|
||||
continue;
|
||||
}
|
||||
self::assertTrue(
|
||||
class_exists($handler),
|
||||
"Module '{$module['id']}' deactivation_handler class '{$handler}' not found"
|
||||
);
|
||||
self::assertTrue(
|
||||
is_subclass_of($handler, ModuleDeactivationHandler::class)
|
||||
|| in_array(ModuleDeactivationHandler::class, class_implements($handler) ?: [], true),
|
||||
"Module '{$module['id']}' deactivation_handler class '{$handler}' "
|
||||
. "must implement ModuleDeactivationHandler interface"
|
||||
);
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── Session key convention ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Module session providers must use the 'module.<id>.' prefix for all session keys.
|
||||
*
|
||||
* Scans session provider classes for $_SESSION[ and ->set( patterns and verifies
|
||||
* that the key argument starts with 'module.<module_id>.'.
|
||||
*/
|
||||
public function testSessionProvidersUseModulePrefixedKeys(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->sessionProviders as $providerClass) {
|
||||
if (!class_exists($providerClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($providerClass);
|
||||
$file = $reflection->getFileName();
|
||||
if ($file === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedPrefix = 'module.' . $module['id'] . '.';
|
||||
|
||||
// Check $_SESSION['...'] writes and unset($_SESSION['...'])
|
||||
if (preg_match_all('/\$_SESSION\[\'([^\']+)\'\]/', $content, $matches)) {
|
||||
foreach ($matches[1] as $key) {
|
||||
self::assertStringStartsWith(
|
||||
$expectedPrefix,
|
||||
$key,
|
||||
"Module '{$module['id']}' session provider '{$providerClass}' uses session key "
|
||||
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check ->set('...', ...) calls on session store
|
||||
if (preg_match_all('/->set\(\s*\'([^\']+)\'/', $content, $matches)) {
|
||||
foreach ($matches[1] as $key) {
|
||||
self::assertStringStartsWith(
|
||||
$expectedPrefix,
|
||||
$key,
|
||||
"Module '{$module['id']}' session provider '{$providerClass}' uses session store key "
|
||||
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── No hardcoded Core ability imports in module pages ───────────
|
||||
|
||||
public function testModulePagesDoNotImportCoreAbilityConstants(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$pagesDir = $module['path'] . '/pages';
|
||||
if (!is_dir($pagesDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($pagesDir)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Module pages should use their own policy constants, not Core's OperationsAuthorizationPolicy
|
||||
self::assertStringNotContainsString(
|
||||
'OperationsAuthorizationPolicy::ABILITY_',
|
||||
$content,
|
||||
"Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. "
|
||||
. "Use the module's own AuthorizationPolicy instead."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
255
tests/Architecture/ModuleUiContractTest.php
Normal file
255
tests/Architecture/ModuleUiContractTest.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
final class ModuleUiContractTest extends AbstractModuleStructureContractTestCase
|
||||
{
|
||||
public function testUiSlotAbilitiesMatchPolicySupports(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$supportedAbilities = [];
|
||||
foreach ($module['manifest']->authorizationPolicies as $policyClass) {
|
||||
if (!class_exists($policyClass)) {
|
||||
continue;
|
||||
}
|
||||
$reflection = new \ReflectionClass($policyClass);
|
||||
foreach ($reflection->getConstants() as $constValue) {
|
||||
if (is_string($constValue)) {
|
||||
$supportedAbilities[] = $constValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($supportedAbilities === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
|
||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||
foreach ($contributionList as $idx => $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
$ability = trim((string) ($contribution['permission'] ?? ''));
|
||||
if ($ability === '') {
|
||||
continue;
|
||||
}
|
||||
self::assertContains(
|
||||
$ability,
|
||||
$supportedAbilities,
|
||||
"Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] permission '{$ability}' "
|
||||
. "is not declared as a constant on any module authorization policy"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testPermissionsUseObjectContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->permissions as $index => $permission) {
|
||||
self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key");
|
||||
self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description");
|
||||
self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty");
|
||||
self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty");
|
||||
self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1");
|
||||
self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testSchedulerJobsUseFullContractSchema(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->schedulerJobs as $index => $job) {
|
||||
$required = [
|
||||
'job_key',
|
||||
'handler',
|
||||
'label',
|
||||
'description',
|
||||
'default_enabled',
|
||||
'default_timezone',
|
||||
'default_schedule_type',
|
||||
'default_schedule_interval',
|
||||
'default_schedule_time',
|
||||
'default_schedule_weekdays_csv',
|
||||
'default_catchup_once',
|
||||
'allowed_schedule_types',
|
||||
];
|
||||
|
||||
foreach ($required as $requiredKey) {
|
||||
self::assertArrayHasKey(
|
||||
$requiredKey,
|
||||
$job,
|
||||
"Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'"
|
||||
);
|
||||
}
|
||||
|
||||
self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty");
|
||||
self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty");
|
||||
self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty");
|
||||
self::assertContains(
|
||||
(string) $job['default_schedule_type'],
|
||||
['hourly', 'daily', 'weekly'],
|
||||
"Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid"
|
||||
);
|
||||
|
||||
$allowedTypes = array_values($job['allowed_schedule_types']);
|
||||
self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty");
|
||||
foreach ($allowedTypes as $allowedType) {
|
||||
self::assertContains(
|
||||
(string) $allowedType,
|
||||
['hourly', 'daily', 'weekly'],
|
||||
"Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testUiSlotContributionsHaveRequiredKeys(): void
|
||||
{
|
||||
$slotKeyRequirements = [
|
||||
'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
|
||||
'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
|
||||
'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
|
||||
'topbar.right_item' => ['key', 'template'],
|
||||
'layout.body_end_template' => ['key', 'template'],
|
||||
'layout.head_style' => ['key', 'path'],
|
||||
'runtime.component' => ['key', 'script'],
|
||||
];
|
||||
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
|
||||
if (!isset($slotKeyRequirements[$slotName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$required = $slotKeyRequirements[$slotName];
|
||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||
|
||||
foreach ($contributionList as $idx => $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($required as $requiredKey) {
|
||||
self::assertArrayHasKey(
|
||||
$requiredKey,
|
||||
$contribution,
|
||||
"Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testPanelTemplatesExist(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$templateSlots = [
|
||||
'aside.tab_panel' => 'panel_template',
|
||||
'topbar.right_item' => 'template',
|
||||
'layout.body_end_template' => 'template',
|
||||
];
|
||||
|
||||
foreach ($templateSlots as $slotName => $templateKey) {
|
||||
$contributions = $module['manifest']->uiSlots[$slotName] ?? [];
|
||||
if (!is_array($contributions)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($contributions as $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
$template = $contribution[$templateKey] ?? null;
|
||||
if ($template === null) {
|
||||
continue;
|
||||
}
|
||||
$templatePath = (string) $template;
|
||||
if (!str_starts_with($templatePath, '/')) {
|
||||
$templatePath = $module['manifest']->basePath . '/' . $templatePath;
|
||||
}
|
||||
self::assertFileExists(
|
||||
$templatePath,
|
||||
"Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$templatePath}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRuntimeComponentPhasesAreValid(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? [];
|
||||
if (!is_array($runtimeComponents)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($runtimeComponents as $index => $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$phase = strtolower(trim((string) ($contribution['phase'] ?? 'late')));
|
||||
self::assertContains(
|
||||
$phase,
|
||||
['early', 'default', 'late'],
|
||||
"Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testSessionProvidersUseModulePrefixedKeys(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->sessionProviders as $providerClass) {
|
||||
if (!class_exists($providerClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($providerClass);
|
||||
$file = $reflection->getFileName();
|
||||
if ($file === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedPrefix = 'module.' . $module['id'] . '.';
|
||||
|
||||
if (preg_match_all('/\$_SESSION\[\'([^\']+)\'\]/', $content, $matches)) {
|
||||
foreach ($matches[1] as $key) {
|
||||
self::assertStringStartsWith(
|
||||
$expectedPrefix,
|
||||
$key,
|
||||
"Module '{$module['id']}' session provider '{$providerClass}' uses session key "
|
||||
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/->set\(\s*\'([^\']+)\'/', $content, $matches)) {
|
||||
foreach ($matches[1] as $key) {
|
||||
self::assertStringStartsWith(
|
||||
$expectedPrefix,
|
||||
$key,
|
||||
"Module '{$module['id']}' session provider '{$providerClass}' uses session store key "
|
||||
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,8 @@ ohne dass jede Aenderung immer die komplette Suite im Kopf behalten muss.
|
||||
|
||||
## Naechste Refactor-Kandidaten
|
||||
|
||||
- `tests/Architecture/ModuleStructureContractTest.php`
|
||||
- `tests/Architecture/FrontendComponentRuntimeContractTest.php`
|
||||
- `tests/Architecture/FilterDrawerContractTest.php`
|
||||
- `tests/Architecture/PublicPageContractTest.php`
|
||||
- `tests/Architecture/CodexSkillsContractTest.php`
|
||||
- `tests/Architecture/ModuleRegistryContractTest.php`
|
||||
|
||||
Reference in New Issue
Block a user