test(arch): overhaul and complete architecture test suite across all layers

This commit is contained in:
Jérémy Munsch 2026-09-14 19:13:41 +02:00
parent 106d4467c4
commit e83a286e9b
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
13 changed files with 380 additions and 125 deletions

View file

@ -19,7 +19,7 @@ use Infra\Services\Health\LaravelSystemHealthChecker;
use Shared\Pagination;
use Uri\Rfc3986\Uri;
class AppServiceProvider extends ServiceProvider
final class AppServiceProvider extends ServiceProvider
{
#[\Override]
public function register(): void

View file

@ -34,7 +34,7 @@ use Shared\DDD\Event\TransactionalDomainEventDispatcher;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
use Webmozart\Assert\Assert;
class DomainServiceProvider extends ServiceProvider
final class DomainServiceProvider extends ServiceProvider
{
#[\Override]
public function register(): void

View file

@ -19,7 +19,7 @@ use Infra\Listeners\LogClientStatusChanged;
use Infra\Listeners\RecordClientSecurityAudit;
use Infra\Listeners\SendClientSecurityAlertMail;
class EventServiceProvider extends ServiceProvider
final class EventServiceProvider extends ServiceProvider
{
public function boot(): void
{

View file

@ -4,48 +4,13 @@ declare(strict_types=1);
namespace Tests\Architecture;
/** @return array<int, string> */
function enumsIn(string $namespace): array
{
$dir = dirname(__DIR__, 2) . '/src/' . str_replace('\\', '/', $namespace);
$enums = [];
if (! is_dir($dir)) {
return $enums;
}
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir)) as $file) {
$class = $namespace . str_replace([$dir, '.php', '/'], ['', '', '\\'], $file->getPathname());
if ($file->isFile() && enum_exists($class)) {
$enums[] = $class;
}
}
return $enums;
}
/** @return array<int, string> */
function valueObjectsIn(string $baseNamespace): array
{
$dir = dirname(__DIR__, 2) . '/src/' . str_replace('\\', '/', $baseNamespace);
$namespaces = [];
if (! is_dir($dir)) {
return $namespaces;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir() && $item->getBasename() === 'ValueObjects') {
$relativePath = str_replace([$dir, '/'], ['', '\\'], $item->getPathname());
$namespaces[] = $baseNamespace . $relativePath;
}
}
return $namespaces;
}
use Application\Security\Models\ValueObjects\Request\HttpMethod;
use Shared\DDD\Event\Event;
use Shared\DDD\Port\Directory;
use Shared\DDD\Port\Repository;
use Shared\DDD\Usecase\Input;
use Shared\DDD\Usecase\Output;
use Shared\DDD\Usecase\Usecase;
describe('Application Architecture Rules', function (): void {
arch('Application layer does not depend on Presentation, Infra, or App')
@ -105,11 +70,82 @@ describe('Application Architecture Rules', function (): void {
->toUseStrictTypes();
arch('Application Usecase classes are final and readonly')
->expect('Application\Arcadia\Usecase')
->expect([
'Application\Arcadia\Usecase',
'Application\Security\Usecases',
])
->classes()
->toBeReadonly()
->toBeFinal();
arch('Classes implementing Shared\DDD\Usecase\Usecase are final and readonly')
->expect(Usecase::class)
->classes()
->toBeReadonly()
->toBeFinal()
->ignoring(enumsIn('Application\Arcadia\Usecase'));
->ignoring(Usecase::class);
arch('Application Input DTOs implement Shared\DDD\Usecase\Input and are final and readonly')
->expect(Input::class)
->classes()
->toBeReadonly()
->toBeFinal()
->ignoring(Input::class);
arch('Application Output DTOs implement Shared\DDD\Usecase\Output and are final and readonly')
->expect(Output::class)
->classes()
->toBeReadonly()
->toBeFinal()
->ignoring(Output::class);
arch('Application Health DTOs are final and readonly')
->expect('Application\Health\Dto')
->classes()
->toBeReadonly()
->toBeFinal();
arch('Application Config classes are final and readonly')
->expect([
'Application\Arcadia\Config',
'Application\Health\Config',
'Application\Security\Config',
])
->classes()
->toBeReadonly()
->toBeFinal();
arch('Application Port Directories and Repositories contain only interfaces')
->expect([
'Application\Security\Ports\Directories',
'Application\Security\Ports\Repositories',
])
->classes()
->toBeInterface();
arch('Application Port Directories extend Shared\DDD\Port\Directory')
->expect('Application\Security\Ports\Directories')
->toExtend(Directory::class);
arch('Application Port Repositories extend Shared\DDD\Port\Repository')
->expect('Application\Security\Ports\Repositories')
->toExtend(Repository::class);
arch('Application Events implement Shared\DDD\Event\Event and are final and readonly')
->expect('Application\Security\Events')
->toImplement(Event::class)
->classes()
->toBeReadonly()
->toBeFinal();
arch('Application Security Value Objects are final and readonly')
->expect('Application\Security\Models\ValueObjects')
->classes()
->toBeReadonly()
->toBeFinal()
->ignoring([
HttpMethod::class,
]);
arch('Application layer must not contain Presenters')
->expect('Application')
@ -120,11 +156,4 @@ describe('Application Architecture Rules', function (): void {
->expect('Application')
->classes()
->not->toHaveSuffix('View');
arch('Application Value Objects are final and readonly')
->expect(valueObjectsIn('Application'))
->classes()
->toBeReadonly()
->toBeFinal()
->ignoring(enumsIn('Application'));
});

View file

@ -4,34 +4,35 @@ declare(strict_types=1);
namespace Tests\Architecture;
/**
* @param array<int, string> $directories
* @return array<int, string>
*/
function getTestFiles(array $directories): array
{
$files = [];
foreach ($directories as $dir) {
$path = dirname(__DIR__, 2) . '/' . ltrim($dir, '/');
if (! is_dir($path)) {
continue;
}
$dirIterator = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS);
$iterator = new \RecursiveIteratorIterator($dirIterator);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->isFile() && str_ends_with($file->getFilename(), 'Test.php')) {
$files[] = $file->getPathname();
if (! function_exists('Tests\Architecture\getTestFiles')) {
/**
* @param array<int, string> $directories
* @return array<int, string>
*/
function getTestFiles(array $directories): array
{
$files = [];
foreach ($directories as $dir) {
$path = dirname(__DIR__, 2) . '/' . ltrim($dir, '/');
if (! is_dir($path)) {
continue;
}
$dirIterator = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS);
$iterator = new \RecursiveIteratorIterator($dirIterator);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->isFile() && str_ends_with($file->getFilename(), 'Test.php')) {
$files[] = $file->getPathname();
}
}
}
}
return $files;
return $files;
}
}
describe('Coding Standards & Project Rules', function (): void {
arch()->preset()->php();
// Le helper assert natif est interdit en faveur de Webmozart\Assert
arch()->preset()->security();
arch('assert is not used in src (use Webmozart\Assert instead)')

View file

@ -4,6 +4,10 @@ declare(strict_types=1);
namespace Tests\Architecture;
use Domain\Models\ValueObjects\Employees\EmployeeSortColumn;
use Domain\Models\ValueObjects\Inco\IncoSheetSortColumn;
use Domain\Models\ValueObjects\Inco\PortionType;
use Shared\DDD\Event\Event;
use Shared\DDD\Model\AggregateRoot;
use Shared\DDD\Model\Entity;
use Shared\DDD\Model\ReadModel;
@ -33,6 +37,39 @@ describe('Domain Architecture Rules', function (): void {
->expect('Domain\Ports\Repositories')
->toExtend(Repository::class);
arch('Domain Value Objects are final and readonly')
->expect('Domain\Models\ValueObjects')
->classes()
->toBeReadonly()
->toBeFinal()
->ignoring([
EmployeeSortColumn::class,
IncoSheetSortColumn::class,
PortionType::class,
]);
arch('Domain Events implement Shared\DDD\Event\Event and are final and readonly')
->expect('Domain\Events')
->toImplement(Event::class)
->classes()
->toBeReadonly()
->toBeFinal();
arch('Domain Builders are final')
->expect('Domain\Builders')
->classes()
->toBeFinal();
arch('Domain Services are final and readonly')
->expect('Domain\Services')
->classes()
->toBeReadonly()
->toBeFinal();
arch('Domain exceptions extend native DomainException')
->expect('Domain\Exceptions')
->toExtend(\DomainException::class);
arch('Domain does not depend on Application, Presentation, Infra, or App')
->expect('Domain')
->not->toUse([
@ -50,15 +87,6 @@ describe('Domain Architecture Rules', function (): void {
'Symfony',
]);
arch('Domain Value Objects are readonly')
->expect('Domain\Models\ValueObjects')
->classes()
->toBeReadonly();
arch('Domain exceptions extend native DomainException')
->expect('Domain\Exceptions')
->toExtend(\DomainException::class);
arch('Domain does not use Laravel helper functions')
->expect('Domain')
->not->toUse([
@ -94,6 +122,10 @@ describe('Domain Architecture Rules', function (): void {
'trans_choice',
'view',
]);
arch('Domain layer uses strict types')
->expect('Domain')
->toUseStrictTypes();
});
describe('AggregateRoot & ReadModel Architecture Rules', function (): void {

View file

@ -4,19 +4,72 @@ declare(strict_types=1);
namespace Tests\Architecture;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
use Shared\DDD\Port\Directory;
use Shared\DDD\Port\Repository;
describe('Infra Architecture Rules', function (): void {
arch('Infra does not depend on App namespace')
->expect('Infra')
->not->toUse('App');
arch('Infra Eloquent Database Models extend Eloquent Model')
arch('Infra Eloquent Database Models extend Eloquent Model and are final')
->expect('Infra\Database\Models')
->toExtend(Model::class);
->toExtend(Model::class)
->classes()
->toBeFinal();
arch('Infra Eloquent Database Models are final')
->expect('Infra\Database\Models')
arch('Infra Service Providers extend ServiceProvider and are final')
->expect('Infra\Providers')
->toExtend(ServiceProvider::class)
->classes()
->toBeFinal();
arch('Infra Casts implement CastsAttributes and are final')
->expect('Infra\Casts')
->toImplement(CastsAttributes::class)
->classes()
->toBeFinal();
arch('Infra directory adapters implement Directory port and are final')
->expect('Infra\Adapters\Directories')
->toImplement(Directory::class)
->classes()
->toBeFinal();
arch('Infra repository adapters implement Repository port and are final')
->expect('Infra\Adapters\Repositories')
->toImplement(Repository::class)
->classes()
->toBeFinal();
arch('Infra Mappers are final and readonly')
->expect('Infra\Adapters\Mappers')
->classes()
->toBeReadonly()
->toBeFinal();
arch('Infra Parsers are final and readonly')
->expect('Infra\Adapters\Parsers')
->classes()
->toBeReadonly()
->toBeFinal();
arch('Infra Jobs are final')
->expect('Infra\Jobs')
->classes()
->toBeFinal();
arch('Infra Listeners are final')
->expect('Infra\Listeners')
->classes()
->toBeFinal();
arch('Infra Mailables are final')
->expect('Infra\Mail')
->classes()
->toBeFinal();
arch('Infra layer uses strict types')

View file

@ -4,9 +4,15 @@ declare(strict_types=1);
namespace Tests\Architecture;
use Application\Security\Config\DevClientCredentials;
use Illuminate\Console\Command;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\ServiceProvider;
use Presentation\Api\V1\Contracts\ApiResponse;
use Shared\DDD\Port\Directory;
use Shared\DDD\Port\Repository;
use Shared\DDD\Usecase\Presenter;
describe('Naming Conventions in Domain', function (): void {
arch('Port Directory interfaces have Directory suffix')
@ -16,34 +22,80 @@ describe('Naming Conventions in Domain', function (): void {
arch('Port Repository interfaces have Repository suffix')
->expect('Domain\Ports\Repositories')
->toHaveSuffix('Repository');
arch('Domain Builders have Builder suffix')
->expect('Domain\Builders')
->toHaveSuffix('Builder');
arch('Domain Exceptions have Exception suffix')
->expect('Domain\Exceptions')
->toHaveSuffix('Exception');
});
describe('Naming Conventions in Application', function (): void {
arch('Application usecases use strict types')
->expect('Application\Arcadia\Usecase')
->toUseStrictTypes();
arch('Application Port Directory interfaces have Directory suffix')
->expect('Application\Security\Ports\Directories')
->toHaveSuffix('Directory');
arch('Application Port Repository interfaces have Repository suffix')
->expect('Application\Security\Ports\Repositories')
->toHaveSuffix('Repository');
arch('Application Config classes have Config suffix')
->expect([
'Application\Arcadia\Config',
'Application\Health\Config',
'Application\Security\Config',
])
->classes()
->toHaveSuffix('Config')
->ignoring([
DevClientCredentials::class,
]);
arch('Application Exception classes have Exception suffix')
->expect([
'Application\Arcadia\Exceptions',
'Application\Health\Exceptions',
'Application\Security\Exceptions',
])
->toHaveSuffix('Exception');
});
describe('Naming Conventions in Presentation', function (): void {
arch('Controllers have Controller suffix')
->expect('Presentation\Api\V1\Http\Controllers')
->expect([
'Presentation\Api\V1\Http\Controllers',
'Presentation\Web\Http\Controllers',
])
->toHaveSuffix('Controller');
arch('Requests extending FormRequest must have Request suffix')
->expect('Presentation\Api\V1\Http\Requests')
->toExtend('Illuminate\Foundation\Http\FormRequest')
->toExtend(FormRequest::class)
->toHaveSuffix('Request');
arch('Responses implementing ApiResponse must have Response suffix')
->expect('Presentation\Api\V1\Http\Responses')
->toImplement(ApiResponse::class)
->toHaveSuffix('Response');
arch('Console Commands extending Command must have Command suffix')
->expect('Presentation\Console\Commands')
->toExtend(Command::class)
->toHaveSuffix('Command');
arch('Classes implementing Presenter must have Presenter suffix')
->expect(Presenter::class)
->classes()
->toHaveSuffix('Presenter')
->ignoring(Presenter::class);
});
describe('Naming Conventions in Infra', function (): void {
arch('Infra classes extending ServiceProvider must have Provider suffix')
->expect('Infra\Providers')
->toExtend('Illuminate\Support\ServiceProvider')
->toExtend(ServiceProvider::class)
->toHaveSuffix('Provider');
arch('Infra directory adapters implementing Directory port must have Directory suffix')
@ -55,4 +107,21 @@ describe('Naming Conventions in Infra', function (): void {
->expect('Infra\Adapters\Repositories')
->toImplement(Repository::class)
->toHaveSuffix('Repository');
arch('Infra Mappers must have Mapper suffix')
->expect('Infra\Adapters\Mappers')
->toHaveSuffix('Mapper');
arch('Infra Parsers must have Parser suffix')
->expect('Infra\Adapters\Parsers')
->toHaveSuffix('Parser');
arch('Infra Casts implementing CastsAttributes must have Cast suffix')
->expect('Infra\Casts')
->toImplement(CastsAttributes::class)
->toHaveSuffix('Cast');
arch('Infra Jobs must have Job suffix')
->expect('Infra\Jobs')
->toHaveSuffix('Job');
});

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Architecture;
use Illuminate\Foundation\Http\FormRequest;
use Presentation\Api\V1\Contracts\ApiResponse;
describe('Presentation Architecture Rules', function (): void {
@ -15,17 +16,27 @@ describe('Presentation Architecture Rules', function (): void {
'App',
]);
arch('Presentation Controllers are invokable single action controllers')
->expect('Presentation\Api\V1\Http\Controllers')
->toBeInvokable();
arch('Presentation Controllers are invokable single action controllers and are final')
->expect([
'Presentation\Api\V1\Http\Controllers',
'Presentation\Web\Http\Controllers',
])
->toBeInvokable()
->classes()
->toBeFinal();
arch('Presentation Responses implement Presentation\Api\V1\Contracts\ApiResponse')
arch('Presentation Responses implement Presentation\Api\V1\Contracts\ApiResponse and are final and readonly')
->expect('Presentation\Api\V1\Http\Responses')
->toImplement(ApiResponse::class);
->toImplement(ApiResponse::class)
->classes()
->toBeReadonly()
->toBeFinal();
arch('Presentation FormRequests extend Illuminate\Foundation\Http\FormRequest')
arch('Presentation FormRequests extend Illuminate\Foundation\Http\FormRequest and are final')
->expect('Presentation\Api\V1\Http\Requests')
->toExtend('Illuminate\Foundation\Http\FormRequest');
->toExtend(FormRequest::class)
->classes()
->toBeFinal();
arch('Presentation FormRequests do not contain toInput method')
->expect('Presentation\Api\V1\Http\Requests')
@ -35,6 +46,29 @@ describe('Presentation Architecture Rules', function (): void {
->expect('Presentation\Api\V1\Http\Requests')
->not->toHaveMethod('authorize');
arch('Presentation DTOs are final and readonly')
->expect('Presentation\Api\V1\Dto')
->classes()
->toBeReadonly()
->toBeFinal();
arch('Presentation Middlewares are final')
->expect('Presentation\Api\V1\Http\Middlewares')
->classes()
->toBeFinal();
arch('Presentation Console Commands extend Illuminate\Console\Command and are final')
->expect('Presentation\Console\Commands')
->toExtend('Illuminate\Console\Command')
->classes()
->toBeFinal();
arch('Presentation Console Presenters are final and readonly')
->expect('Presentation\Console\Presenters')
->classes()
->toBeReadonly()
->toBeFinal();
arch('Presentation layer uses strict types')
->expect('Presentation')
->toUseStrictTypes();

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Tests\Architecture;
use Shared\DDD\Model\EntityId;
describe('Shared Architecture Rules', function (): void {
arch('Shared layer does not depend on Domain, Application, Presentation, Infra, or App')
->expect('Shared')
@ -59,6 +61,35 @@ describe('Shared Architecture Rules', function (): void {
'view',
]);
arch('Shared Value Objects are final and readonly')
->expect('Shared\ValueObjects')
->classes()
->toBeReadonly()
->toBeFinal();
arch('Shared DDD Ports contain only interfaces')
->expect('Shared\DDD\Port')
->classes()
->toBeInterface();
arch('Shared DDD Models contain interfaces')
->expect('Shared\DDD\Model')
->classes()
->toBeInterface()
->ignoring([
EntityId::class,
]);
arch('Shared DDD Events contain only interfaces')
->expect('Shared\DDD\Event')
->classes()
->toBeInterface();
arch('Shared DDD Security contracts contain only interfaces')
->expect('Shared\DDD\Security')
->classes()
->toBeInterface();
arch('Shared layer uses strict types')
->expect('Shared')
->toUseStrictTypes();

View file

@ -10,11 +10,8 @@ use Tests\TestCase as LaravelTestCase;
BypassFinals::enable(bypassReadOnly: false, bypassFinal: true);
BypassFinals::denyPaths([
'*/Usecase/*',
'*/Dto/*',
'*/Models/*',
'*/Shared/DDD/*',
'*/ValueObjects/*',
'*/src/*',
'*/tests/*',
]);
pest()

View file

@ -4,15 +4,19 @@ declare(strict_types=1);
namespace Tests\Unit\Infra\Adapters\Mappers;
use Domain\Models\Entities\Brand;
use Domain\Models\Entities\Inco\IncoSheet;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Services\BrandResolver;
use Infra\Adapters\Mappers\IncoMapper;
use PHPUnit\Framework\TestCase;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Services\BrandCodeGenerator;
use Infra\Adapters\Directories\InMemoryArcadiaBrandDirectory;
use Infra\Adapters\Repositories\InMemoryBrandRepository;
use Shared\DDD\Event\DomainEventDispatcher;
use Ramsey\Uuid\Uuid;
covers(IncoMapper::class);
@ -21,17 +25,19 @@ uses(TestCase::class);
describe('IncoMapper', function (): void {
it('maps an array row to complete IncoSheet entity', function (): void {
$brandResolver = $this->createMock(BrandResolver::class);
$mockBrand = Brand::hydrate(
$arcadiaDirectory = new InMemoryArcadiaBrandDirectory();
$brandRepository = new InMemoryBrandRepository();
$brand = Brand::hydrate(
id: new BrandId(Uuid::fromString('018e6e5b-1111-7000-8000-000000000001')),
name: new BrandName('Le Gaulois'),
code: new BrandCode('LGL01'),
arcadiaId: new ArcadiaId('1'),
);
$brandRepository->add($brand);
$brandResolver->expects($this->once())
->method('resolve')
->willReturn($mockBrand);
$brandCodeGenerator = new BrandCodeGenerator();
$dispatcher = \Mockery::mock(DomainEventDispatcher::class);
$brandResolver = new BrandResolver($arcadiaDirectory, $brandRepository, $brandCodeGenerator, $dispatcher);
$mapper = new IncoMapper($brandResolver);

View file

@ -14,6 +14,8 @@ use Infra\Cache\BrandCache;
use Infra\Listeners\InvalidateBrandCache;
use Mockery;
use Ramsey\Uuid\Uuid;
use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
covers(InvalidateBrandCache::class);
@ -28,16 +30,16 @@ describe('InvalidateBrandCache Listener Unit Tests', function (): void {
occurredAt: new \DateTimeImmutable(),
);
/** @var BrandCache&Mockery\MockInterface $brandCacheMock */
$brandCacheMock = Mockery::mock(BrandCache::class);
$brandCacheMock->shouldReceive('reset')
->once();
$cacheStore = Mockery::mock(CacheInterface::class);
$cacheStore->shouldReceive('get')->with('brand:all')->andReturn(null);
$cacheStore->shouldReceive('delete')->with('brand:all')->once()->andReturn(true);
$logger = Mockery::mock(LoggerInterface::class);
$listener = new InvalidateBrandCache($brandCacheMock);
$brandCache = new BrandCache($cacheStore, $logger);
$listener = new InvalidateBrandCache($brandCache);
// WHEN
$listener->handle($event);
// THEN
expect(true)->toBeTrue();
});
@ -53,12 +55,13 @@ describe('InvalidateBrandCache Listener Unit Tests', function (): void {
occurredAt: new \DateTimeImmutable(),
);
/** @var BrandCache&Mockery\MockInterface $brandCacheMock */
$brandCacheMock = Mockery::mock(BrandCache::class);
$brandCacheMock->shouldReceive('reset')
->once();
$cacheStore = Mockery::mock(CacheInterface::class);
$cacheStore->shouldReceive('get')->with('brand:all')->andReturn(null);
$cacheStore->shouldReceive('delete')->with('brand:all')->once()->andReturn(true);
$logger = Mockery::mock(LoggerInterface::class);
$listener = new InvalidateBrandCache($brandCacheMock);
$brandCache = new BrandCache($cacheStore, $logger);
$listener = new InvalidateBrandCache($brandCache);
// WHEN
$listener->handle($event);