Repo Interface für tests
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
132
tests/Architecture/RepositoryInterfaceContractTest.php
Normal file
132
tests/Architecture/RepositoryInterfaceContractTest.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user