forked from fa/breadcrumb-the-shire
Resolve all non-false-positive PHPStan 2 findings, reducing the baseline from 486 to 382 entries (only unused-public false positives remain). Categories fixed: - Remove 39 redundant type checks (is_string, is_array, is_object, method_exists) - Remove 12 no-op array_values() on already-sequential lists - Simplify 13 null-coalesce on guaranteed offsets - Remove 8 always-true instanceof checks - Replace 12 redundant test assertions (assertTrue(true) → addToAssertionCount) - Simplify 6 unnecessary nullsafe operators (?-> → ->) - Fix 6 always-true !== '' comparisons - Fix remaining: unset.offset, return.unusedType, staticMethod narrowing 53 files changed across core/, modules/, pages/, and tests/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
256 lines
11 KiB
PHP
256 lines
11 KiB
PHP
<?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 = $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);
|
|
}
|
|
}
|