Repo Interface für tests

This commit is contained in:
2026-03-05 08:26:51 +01:00
parent 8f4dd5840d
commit 4b31fc7664
171 changed files with 3518 additions and 2297 deletions

View File

@@ -108,9 +108,22 @@ class AuthzUiContractTest extends TestCase
$aside = $this->readProjectFile('templates/partials/app-main-aside.phtml');
$this->assertStringContainsString("\$viewAuth['layout']", $aside);
$this->assertStringContainsString('layoutHasAdminPanel(', $aside);
$this->assertStringContainsString('$layoutNav', $aside);
$iconBar = $this->readProjectFile('templates/partials/app-main-aside-icon-bar.phtml');
$this->assertStringContainsString("\$viewAuth['layout']", $iconBar);
$this->assertStringContainsString('layoutHasAdminPanel(', $iconBar);
}
public function testAsideTemplateDoesNotReadRequestOrResolveServicesDirectly(): void
{
$aside = $this->readProjectFile('templates/partials/app-main-aside.phtml');
$this->assertStringNotContainsString('$_GET', $aside);
$this->assertStringNotContainsString('$_SESSION', $aside);
$this->assertStringNotContainsString('TenantAvatarService', $aside);
$this->assertStringNotContainsString('app(', $aside);
}

View File

@@ -55,10 +55,72 @@ class CoreStarterkitContractTest extends TestCase
$this->assertSame([], $violations, "ApiResponse::readJsonBody usage found in pages/api/v1/:\n" . implode("\n", $violations));
}
public function testLegacyAndTemplateModulesAreRemoved(): void
{
$root = $this->projectRootPath();
$this->assertFileDoesNotExist(
$root . '/web/js/components/app-filter-overflow.js',
'Legacy filter overflow module must remain removed.'
);
$this->assertFileDoesNotExist(
$root . '/web/js/components/app-toggle-toolbar.js',
'Legacy toolbar toggle module must remain removed.'
);
$this->assertFileDoesNotExist(
$root . '/web/js/components/_template.js',
'Component template module must remain removed from production JS path.'
);
}
public function testTemplatesDoNotUseLegacyFilterToggleMarkup(): void
{
$pattern = '/data-(?:toolbar-toggle|filter-overflow|filter-toggle)/';
$violations = array_merge(
$this->findPatternMatchesInFiles('templates', $pattern, ['phtml']),
$this->findPatternMatchesInFiles('pages', $pattern, ['phtml'])
);
sort($violations);
$this->assertSame([], $violations, "Legacy filter toggle markup found in templates/pages:\n" . implode("\n", $violations));
}
public function testPagesAndTemplatesDoNotUseInlineModuleScripts(): void
{
$pattern = '/<script\b(?=[^>]*\btype=["\']module["\'])(?![^>]*\bsrc=)[^>]*>/i';
$violations = array_merge(
$this->findPatternMatchesInFiles('templates', $pattern, ['phtml']),
$this->findPatternMatchesInFiles('pages', $pattern, ['phtml'])
);
sort($violations);
$this->assertSame([], $violations, "Inline module scripts found in templates/pages:\n" . implode("\n", $violations));
}
public function testPagesAndTemplatesUseAssetVersionForJsUrls(): void
{
$pattern = '/asset\(\s*[\'"][^\'"]+\.js/i';
$violations = array_merge(
$this->findPatternMatchesInFiles('templates', $pattern, ['phtml']),
$this->findPatternMatchesInFiles('pages', $pattern, ['phtml'])
);
sort($violations);
$this->assertSame([], $violations, "asset(...) JS URLs found in templates/pages:\n" . implode("\n", $violations));
}
/**
* @return list<string>
*/
private function findPatternMatchesInPhpFiles(string $relativeDirectory, string $pattern): array
{
return $this->findPatternMatchesInFiles($relativeDirectory, $pattern, ['php']);
}
/**
* @param list<string> $extensions
* @return list<string>
*/
private function findPatternMatchesInFiles(string $relativeDirectory, string $pattern, array $extensions): array
{
$root = $this->projectRootPath();
$basePath = $root . '/' . $relativeDirectory;
@@ -69,7 +131,7 @@ class CoreStarterkitContractTest extends TestCase
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
if (!$file->isFile() || !in_array($file->getExtension(), $extensions, true)) {
continue;
}

View File

@@ -8,6 +8,15 @@ class FilterDrawerContractTest extends TestCase
{
use ProjectFileAssertionSupport;
private function readTemplatePageModule(string $templateFile): string
{
$template = $this->readProjectFile($templateFile);
$matched = preg_match("/assetVersion\\('js\\/pages\\/([^']+\\.js)'\\)/", $template, $captures);
$this->assertSame(1, $matched, $templateFile . ' must reference a page module via assetVersion().');
return $this->readProjectFile('web/js/pages/' . $captures[1]);
}
private function usesSharedListFiltersPartial(string $content): bool
{
return str_contains($content, "require templatePath('partials/app-list-filters.phtml');");
@@ -38,10 +47,11 @@ class FilterDrawerContractTest extends TestCase
{
foreach ($this->drawerTemplateFiles() as $file) {
$content = $this->readProjectFile($file);
$moduleContent = $this->readTemplatePageModule($file);
$usesPartial = $this->usesSharedListFiltersPartial($content);
$this->assertStringContainsString('initStandardListPage(', $content, $file);
$this->assertStringContainsString("mode: 'drawer'", $content, $file);
$this->assertStringNotContainsString('initListFilterExperience(', $content, $file);
$this->assertStringContainsString('initStandardListPage(', $moduleContent, $file);
$this->assertStringContainsString("mode: 'drawer'", $moduleContent, $file);
$this->assertStringNotContainsString('initListFilterExperience(', $moduleContent, $file);
if (!$usesPartial) {
$this->assertStringContainsString('role="dialog"', $content, $file);
$this->assertStringContainsString('aria-modal="true"', $content, $file);
@@ -55,9 +65,10 @@ class FilterDrawerContractTest extends TestCase
public function testAddressBookSaveFilterReadsAppliedGridState(): void
{
$content = $this->readProjectFile('pages/address-book/index(default).phtml');
$moduleContent = $this->readProjectFile('web/js/pages/address-book-index.js');
$this->assertStringContainsString('new URL(gridConfig.baseUrl()).searchParams', $content);
$this->assertStringNotContainsString('const state = collectFilterState();', $content);
$this->assertStringContainsString('new URL(gridConfig.baseUrl()).searchParams', $moduleContent);
$this->assertStringNotContainsString('const state = collectFilterState();', $moduleContent);
$this->assertStringContainsString('data-filter-chips-clear-all-label', $content);
$this->assertStringContainsString('data-filter-chips-remove-label', $content);
$this->assertStringContainsString('data-filter-live-applied-message', $content);
@@ -67,9 +78,8 @@ class FilterDrawerContractTest extends TestCase
public function testUsersResetKeepsTenantParam(): void
{
$content = $this->readProjectFile('pages/admin/users/index(default).phtml');
$this->assertStringContainsString("preserveFilterParams: ['tenant']", $content);
$moduleContent = $this->readProjectFile('web/js/pages/admin-users-index.js');
$this->assertStringContainsString("preserveFilterParams: ['tenant']", $moduleContent);
}
public function testFilterDrawerAndExperienceImplementFocusTrapAndDirtyApplyUi(): void

View File

@@ -8,6 +8,15 @@ class ListFilterContractTest extends TestCase
{
use ProjectFileAssertionSupport;
private function readTemplatePageModule(string $templateFile): string
{
$template = $this->readProjectFile($templateFile);
$matched = preg_match("/assetVersion\\('js\\/pages\\/([^']+\\.js)'\\)/", $template, $captures);
$this->assertSame(1, $matched, $templateFile . ' must reference a page module via assetVersion().');
return $this->readProjectFile('web/js/pages/' . $captures[1]);
}
private function usesSharedListFiltersPartial(string $content): bool
{
return str_contains($content, "require templatePath('partials/app-list-filters.phtml');");
@@ -233,12 +242,13 @@ class ListFilterContractTest extends TestCase
{
foreach ($this->listIndexTemplates() as $file) {
$content = $this->readProjectFile($file);
$moduleContent = $this->readTemplatePageModule($file);
$usesPartial = $this->usesSharedListFiltersPartial($content);
$this->assertTrue(
$usesPartial || str_contains($content, 'renderGridFilterToolbar('),
$file . ' does not render toolbar directly and does not include shared list-filters partial.'
);
$this->assertStringContainsString('initStandardListPage(', $content, $file);
$this->assertStringContainsString('initStandardListPage(', $moduleContent, $file);
$this->assertStringNotContainsString('gridFiltersFromSchema(', $content, $file);
$this->assertStringNotContainsString('data-toolbar-toggle', $content, $file);
$this->assertStringNotContainsString('data-filter-overflow', $content, $file);
@@ -263,8 +273,9 @@ class ListFilterContractTest extends TestCase
{
foreach ($this->drawerListTemplates() as $file) {
$content = $this->readProjectFile($file);
$moduleContent = $this->readTemplatePageModule($file);
$usesPartial = $this->usesSharedListFiltersPartial($content);
$this->assertStringContainsString("mode: 'drawer'", $content, $file);
$this->assertStringContainsString("mode: 'drawer'", $moduleContent, $file);
if (!$usesPartial) {
$this->assertStringContainsString('data-filter-drawer-open', $content, $file);
$this->assertStringContainsString('data-filter-drawer-apply', $content, $file);
@@ -278,7 +289,8 @@ class ListFilterContractTest extends TestCase
{
foreach ($this->searchOnlyTemplates() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString("mode: 'search-only'", $content, $file);
$moduleContent = $this->readTemplatePageModule($file);
$this->assertStringContainsString("mode: 'search-only'", $moduleContent, $file);
$this->assertStringNotContainsString('data-filter-drawer-open', $content, $file);
$this->assertStringNotContainsString('data-filter-drawer-apply', $content, $file);
$this->assertStringNotContainsString('data-active-filter-chips', $content, $file);

View File

@@ -0,0 +1,132 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* Enforces that every instance-based repository class has a co-located interface
* and correctly implements it.
*
* Excluded from this contract (static-method-only classes, no instance interface possible):
* - TenantCustomFieldDefinitionRepository
* - TenantCustomFieldOptionRepository
* - UserCustomFieldValueRepository
* - UserCustomFieldValueOptionRepository
* - PageRepository
* - PageContentRepository
*
* Also excluded (utility, not a domain repository):
* - RepoQuery
*/
class RepositoryInterfaceContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/** @var list<string> */
private const EXCLUDED_CLASSES = [
'MintyPHP\\Repository\\Content\\PageContentRepository',
'MintyPHP\\Repository\\Content\\PageRepository',
'MintyPHP\\Repository\\CustomField\\TenantCustomFieldDefinitionRepository',
'MintyPHP\\Repository\\CustomField\\TenantCustomFieldOptionRepository',
'MintyPHP\\Repository\\CustomField\\UserCustomFieldValueRepository',
'MintyPHP\\Repository\\CustomField\\UserCustomFieldValueOptionRepository',
'MintyPHP\\Repository\\Support\\RepoQuery',
];
/**
* @return array<string, array{class: string, interfaceClass: string, interfaceFile: string}>
*/
private function collectRepositoryPairs(): array
{
$root = $this->projectRootPath();
$repoDir = $root . '/lib/Repository';
$pairs = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($repoDir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if ($file->getExtension() !== 'php') {
continue;
}
$filename = $file->getFilename();
// Only concrete repository files (not interfaces, not utilities)
if (!str_ends_with($filename, 'Repository.php')) {
continue;
}
if (str_ends_with($filename, 'RepositoryInterface.php')) {
continue;
}
// Derive PSR-4 class name: lib/Repository/Foo/Bar.php → MintyPHP\Repository\Foo\Bar
$relativePath = ltrim(str_replace($root, '', $file->getPathname()), '/');
$className = 'MintyPHP\\' . str_replace(['/', '.php'], ['\\', ''], substr($relativePath, strlen('lib/')));
if (in_array($className, self::EXCLUDED_CLASSES, true)) {
continue;
}
$interfaceClass = $className . 'Interface';
$interfaceFile = substr($relativePath, 0, -strlen('.php')) . 'Interface.php';
$pairs[$className] = [
'class' => $className,
'interfaceClass' => $interfaceClass,
'interfaceFile' => $interfaceFile,
];
}
ksort($pairs);
return $pairs;
}
public function testEveryRepositoryHasACoLocatedInterface(): void
{
$pairs = $this->collectRepositoryPairs();
$this->assertNotEmpty($pairs, 'No repository classes found — check path resolution.');
$missing = [];
foreach ($pairs as $className => $pair) {
$root = $this->projectRootPath();
if (!file_exists($root . '/' . $pair['interfaceFile'])) {
$missing[] = $pair['interfaceFile'] . ' (for ' . $className . ')';
}
}
$this->assertSame(
[],
$missing,
"Missing interface files:\n" . implode("\n", $missing)
);
}
public function testEveryRepositoryImplementsItsInterface(): void
{
$pairs = $this->collectRepositoryPairs();
$violations = [];
foreach ($pairs as $className => $pair) {
if (!class_exists($className)) {
$violations[] = $className . ' — class not found (autoload issue?)';
continue;
}
$implemented = class_implements($className);
if ($implemented === false || !in_array($pair['interfaceClass'], $implemented, true)) {
$violations[] = $className . ' does not implement ' . $pair['interfaceClass'];
}
}
$this->assertSame(
[],
$violations,
"Repository interface contract violations:\n" . implode("\n", $violations)
);
}
}

View File

@@ -167,8 +167,10 @@ class StatusTaxonomyContractTest extends TestCase
public function testImportAuditIndexTemplateDoesNotUseLegacyStatusMapper(): void
{
$content = $this->readProjectFile('pages/admin/import-audit/index(default).phtml');
$this->assertStringNotContainsString('const statusLabel = (value) =>', $content);
$this->assertStringNotContainsString("if (key === 'running')", $content);
$this->assertStringContainsString('cell.label || \'-\'', $content);
$moduleContent = $this->readProjectFile('web/js/pages/admin-import-audit-index.js');
$this->assertStringNotContainsString('const statusLabel = (value) =>', $moduleContent);
$this->assertStringNotContainsString("if (key === 'running')", $moduleContent);
$this->assertStringContainsString('cell.label || \'-\'', $moduleContent);
$this->assertStringContainsString("assetVersion('js/pages/admin-import-audit-index.js')", $content);
}
}

View File

@@ -2,7 +2,7 @@
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Repository\Audit\ApiAuditLogRepository;
use MintyPHP\Repository\Audit\ApiAuditLogRepositoryInterface;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Http\RequestContext;
use PHPUnit\Framework\TestCase;
@@ -28,7 +28,7 @@ class ApiAuditServiceTest extends TestCase
public function testCurrentRequestIdIsNullBeforeStart(): void
{
$service = new ApiAuditService(new ApiAuditLogRepository());
$service = new ApiAuditService($this->createMock(ApiAuditLogRepositoryInterface::class));
$this->assertNull($service->currentRequestId());
}
@@ -38,7 +38,7 @@ class ApiAuditServiceTest extends TestCase
$_SERVER['REQUEST_URI'] = '/api/v1/me?x=1';
$_GET = ['x' => '1'];
$service = new ApiAuditService(new ApiAuditLogRepository());
$service = new ApiAuditService($this->createMock(ApiAuditLogRepositoryInterface::class));
$service->startRequestContext();
$requestId = $service->currentRequestId();
@@ -58,7 +58,7 @@ class ApiAuditServiceTest extends TestCase
$_SERVER['REQUEST_URI'] = '/api/v1/me';
$_GET = [];
$service = new ApiAuditService(new ApiAuditLogRepository());
$service = new ApiAuditService($this->createMock(ApiAuditLogRepositoryInterface::class));
$service->startRequestContext();
$this->assertNull($service->currentRequestId());

View File

@@ -67,13 +67,13 @@ class SchedulerRunServiceTest extends TestCase
]);
$scheduledJobRepository->expects($this->once())
->method('markRunning')
->with(7, $this->isType('string'))
->with(7, $this->isString())
->willReturn(false);
$scheduledJobRunRepository = $this->createMock(ScheduledJobRunRepository::class);
$scheduledJobRunRepository->expects($this->once())
->method('create')
->with($this->isType('array'))
->with($this->isArray())
->willReturn(1);
$databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
@@ -119,10 +119,10 @@ class SchedulerRunServiceTest extends TestCase
$jobRecord = null;
$runRecord = null;
foreach ($records as $record) {
if (($record['event_type'] ?? '') === 'scheduler.job.run') {
if ($record['event_type'] === 'scheduler.job.run') {
$jobRecord = $record;
}
if (($record['event_type'] ?? '') === 'scheduler.run') {
if ($record['event_type'] === 'scheduler.run') {
$runRecord = $record;
}
}