first commit
This commit is contained in:
45
tests/Architecture/ApplicationServiceDoctrineTest.php
Normal file
45
tests/Architecture/ApplicationServiceDoctrineTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ApplicationServiceDoctrineTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testAuthApplicationServiceKeepsSensitiveAuthenticationLogicInUseCases(): void
|
||||
{
|
||||
$file = $this->projectPath('src/Identity/Application/AuthApplicationService.php');
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\AuthenticateUser', $code);
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ChangePassword', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Repository\\UserRepositoryInterface', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Policy\\PasswordPolicy', $code);
|
||||
}
|
||||
|
||||
public function testPasswordResetApplicationServiceDelegatesWorkflowToUseCases(): void
|
||||
{
|
||||
$file = $this->projectPath('src/Identity/Application/PasswordResetApplicationService.php');
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\RequestPasswordReset', $code);
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ValidatePasswordResetToken', $code);
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ResetPassword', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Repository\\PasswordResetRepositoryInterface', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Kernel\\Mail\\Application\\MailServiceInterface', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Kernel\\Persistence\\Application\\TransactionManagerInterface', $code);
|
||||
}
|
||||
|
||||
public function testMediaApplicationServiceDelegatesMutationsToUseCases(): void
|
||||
{
|
||||
$file = $this->projectPath('src/Media/Application/MediaApplicationService.php');
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertStringContainsString('Netig\Netslim\\Media\\Application\\UseCase\\StoreMedia', $code);
|
||||
self::assertStringContainsString('Netig\Netslim\\Media\\Application\\UseCase\\DeleteMedia', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Media\\Domain\\Service\\MediaStorageInterface', $code);
|
||||
self::assertStringNotContainsString('PDOException', $code);
|
||||
}
|
||||
}
|
||||
91
tests/Architecture/ApplicationServiceVocabularyTest.php
Normal file
91
tests/Architecture/ApplicationServiceVocabularyTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Netig\Netslim\AuditLog\Application\AuditLogServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\AuthServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\PasswordResetServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\UserServiceInterface;
|
||||
use Netig\Netslim\Media\Application\MediaServiceInterface;
|
||||
use Netig\Netslim\Notifications\Application\NotificationServiceInterface;
|
||||
use Netig\Netslim\Settings\Application\SettingsServiceInterface;
|
||||
use Netig\Netslim\Taxonomy\Application\TaxonomyServiceInterface;
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ApplicationServiceVocabularyTest extends ArchitectureTestCase
|
||||
{
|
||||
/**
|
||||
* @return iterable<string, array{0: class-string, 1: list<string>}>
|
||||
*/
|
||||
public static function serviceInterfaces(): iterable
|
||||
{
|
||||
yield 'auth service vocabulary' => [
|
||||
AuthServiceInterface::class,
|
||||
['checkRateLimit', 'recordFailure', 'resetRateLimit', 'checkPasswordResetRateLimit', 'recordPasswordResetAttempt', 'authenticate', 'changePassword', 'isLoggedIn', 'login', 'logout'],
|
||||
];
|
||||
|
||||
yield 'password reset service vocabulary' => [
|
||||
PasswordResetServiceInterface::class,
|
||||
['requestReset', 'validateToken', 'resetPassword'],
|
||||
];
|
||||
|
||||
yield 'authorization service vocabulary' => [
|
||||
AuthorizationServiceInterface::class,
|
||||
['canRole', 'canUser', 'permissionsForRole'],
|
||||
];
|
||||
|
||||
yield 'settings service vocabulary' => [
|
||||
SettingsServiceInterface::class,
|
||||
['all', 'delete', 'get', 'getBool', 'getInt', 'getString', 'has', 'set'],
|
||||
];
|
||||
|
||||
yield 'audit log service vocabulary' => [
|
||||
AuditLogServiceInterface::class,
|
||||
['listRecent', 'record'],
|
||||
];
|
||||
|
||||
yield 'notification service vocabulary' => [
|
||||
NotificationServiceInterface::class,
|
||||
['recent', 'send', 'sendTemplate'],
|
||||
];
|
||||
|
||||
yield 'taxonomy service vocabulary' => [
|
||||
TaxonomyServiceInterface::class,
|
||||
['findAll', 'findPaginated', 'findById', 'findBySlug', 'create', 'delete'],
|
||||
];
|
||||
|
||||
yield 'media service vocabulary' => [
|
||||
MediaServiceInterface::class,
|
||||
['findAll', 'findPaginated', 'findByUserId', 'findByUserIdPaginated', 'findById', 'store', 'getUsageSummary', 'getUsageSummaries', 'delete'],
|
||||
];
|
||||
|
||||
|
||||
yield 'user service vocabulary' => [
|
||||
UserServiceInterface::class,
|
||||
['findAll', 'findPaginated', 'findById', 'delete', 'deleteFromAdministration', 'updateRole', 'updateRoleFromAdministration', 'create'],
|
||||
];
|
||||
}
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('serviceInterfaces')]
|
||||
public function testApplicationServiceInterfacesExposeAnExplicitVocabulary(string $interface, array $expectedMethods): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($interface);
|
||||
$methods = array_values(array_map(
|
||||
static fn (\ReflectionMethod $method): string => $method->getName(),
|
||||
$reflection->getMethods(\ReflectionMethod::IS_PUBLIC),
|
||||
));
|
||||
sort($methods);
|
||||
|
||||
$expected = $expectedMethods;
|
||||
sort($expected);
|
||||
|
||||
self::assertSame(
|
||||
$expected,
|
||||
$methods,
|
||||
sprintf('%s must keep its agreed application vocabulary to stay aligned with the other modules.', $interface),
|
||||
);
|
||||
}
|
||||
}
|
||||
93
tests/Architecture/ApplicationWorkflowShapeTest.php
Normal file
93
tests/Architecture/ApplicationWorkflowShapeTest.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
use ReflectionMethod;
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ApplicationWorkflowShapeTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testUseCasesFollowTheProjectShapeConvention(): void
|
||||
{
|
||||
$files = $this->phpFilesUnder('src/*/Application/UseCase');
|
||||
self::assertNotSame([], $files, 'Expected at least one use case file.');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$class = $this->reflectProjectClass($file);
|
||||
$basename = pathinfo($file, PATHINFO_FILENAME);
|
||||
|
||||
self::assertSame(
|
||||
$basename,
|
||||
$class->getShortName(),
|
||||
$this->fileRuleMessage('Use cases must be named after their file', $file),
|
||||
);
|
||||
self::assertTrue($class->isFinal(), $this->fileRuleMessage('Use cases must be final classes', $file));
|
||||
self::assertTrue($class->isReadOnly(), $this->fileRuleMessage('Use cases must be readonly classes', $file));
|
||||
|
||||
$publicMethods = array_values(array_map(
|
||||
static fn (ReflectionMethod $method): string => $method->getName(),
|
||||
array_filter(
|
||||
$class->getMethods(ReflectionMethod::IS_PUBLIC),
|
||||
static fn (ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $class->getName()
|
||||
&& !$method->isConstructor()
|
||||
&& !$method->isDestructor(),
|
||||
),
|
||||
));
|
||||
sort($publicMethods);
|
||||
|
||||
self::assertSame(
|
||||
['handle'],
|
||||
$publicMethods,
|
||||
$this->fileRuleMessage('Use cases must expose a single public handle(...) entrypoint', $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testCommandsRemainImmutableInputDtos(): void
|
||||
{
|
||||
$files = $this->phpFilesUnder('src/*/Application/Command');
|
||||
self::assertNotSame([], $files, 'Expected at least one command file.');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$basename = pathinfo($file, PATHINFO_FILENAME);
|
||||
$class = $this->reflectProjectClass($file);
|
||||
|
||||
self::assertStringEndsWith('Command', $basename, $this->fileRuleMessage('Command files must end with Command', $file));
|
||||
self::assertSame(
|
||||
$basename,
|
||||
$class->getShortName(),
|
||||
$this->fileRuleMessage('Commands must be named after their file', $file),
|
||||
);
|
||||
self::assertTrue($class->isFinal(), $this->fileRuleMessage('Commands must be final classes', $file));
|
||||
self::assertTrue($class->isReadOnly(), $this->fileRuleMessage('Commands must be readonly classes', $file));
|
||||
}
|
||||
}
|
||||
|
||||
public function testUiLayerDoesNotReferenceUseCasesOrCommandsDirectly(): void
|
||||
{
|
||||
$files = $this->uiFiles();
|
||||
self::assertNotSame([], $files, 'Expected UI files to exist.');
|
||||
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$files,
|
||||
['\\Application\\UseCase\\', '\\Application\\Command\\'],
|
||||
'UI layer must enter a domain through its application service, not through use cases or commands',
|
||||
);
|
||||
}
|
||||
|
||||
private function reflectProjectClass(string $file): ReflectionClass
|
||||
{
|
||||
$relativePath = $this->relativePath($file);
|
||||
$className = 'Netig\Netslim\\' . str_replace(['/', '.php'], ['\\', ''], substr($relativePath, 4));
|
||||
|
||||
try {
|
||||
return new ReflectionClass($className);
|
||||
} catch (ReflectionException $exception) {
|
||||
self::fail($this->fileRuleMessage('Unable to reflect class ' . $className . ': ' . $exception->getMessage(), $file));
|
||||
}
|
||||
}
|
||||
}
|
||||
40
tests/Architecture/HttpRuntimeBoundaryTest.php
Normal file
40
tests/Architecture/HttpRuntimeBoundaryTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class HttpRuntimeBoundaryTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testKernelHttpApplicationContainsOnlyTransverseHttpContracts(): void
|
||||
{
|
||||
$path = $this->projectPath('src/Kernel/Http/Application');
|
||||
self::assertDirectoryExists($path);
|
||||
|
||||
$entries = array_values(array_filter(scandir($path) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame([
|
||||
'Flash',
|
||||
'Session',
|
||||
], $entries, 'Kernel/Http/Application should only expose transverse HTTP contracts.');
|
||||
}
|
||||
|
||||
public function testKernelRuntimeHttpContainsOnlyApprovedAssemblyClasses(): void
|
||||
{
|
||||
$path = $this->projectPath('src/Kernel/Runtime/Http');
|
||||
self::assertDirectoryExists($path);
|
||||
|
||||
$entries = array_values(array_filter(scandir($path) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame([
|
||||
'DefaultErrorHandler.php',
|
||||
'ErrorHandlerConfigurator.php',
|
||||
'HttpApplicationFactory.php',
|
||||
'MiddlewareRegistrar.php',
|
||||
], $entries, 'Kernel/Runtime/Http should contain only the approved HTTP runtime assembly classes.');
|
||||
}
|
||||
}
|
||||
74
tests/Architecture/InstantiationBoundaryTest.php
Normal file
74
tests/Architecture/InstantiationBoundaryTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class InstantiationBoundaryTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testApplicationServicesDoNotInstantiateUseCasesOrPoliciesDirectly(): void
|
||||
{
|
||||
foreach ($this->applicationServiceFiles() as $file) {
|
||||
$violations = $this->collectInstantiationViolations(
|
||||
$file,
|
||||
self::USE_CASE_OR_POLICY_FRAGMENTS,
|
||||
$this->isUseCaseOrPolicyClass(...),
|
||||
);
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
$this->fileRuleMessage(self::MESSAGE_APPLICATION_SERVICE_DI_ONLY, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testControllersDoNotInstantiateApplicationOrDomainDependenciesDirectly(): void
|
||||
{
|
||||
foreach ($this->uiControllerFiles() as $file) {
|
||||
$violations = $this->collectInstantiationViolations(
|
||||
$file,
|
||||
self::APPLICATION_OR_DOMAIN_FRAGMENTS,
|
||||
$this->isApplicationOrDomainClass(...),
|
||||
);
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
$this->fileRuleMessage(self::MESSAGE_CONTROLLER_DI_ONLY, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testUiLayerDoesNotInstantiateInfrastructureOrRepositoryImplementationsDirectly(): void
|
||||
{
|
||||
foreach ($this->uiFiles() as $file) {
|
||||
$violations = $this->collectInstantiationViolations(
|
||||
$file,
|
||||
self::INFRASTRUCTURE_INSTANTIATION_FRAGMENTS,
|
||||
$this->isInfrastructureOrRepositoryClass(...),
|
||||
);
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
$this->fileRuleMessage(self::MESSAGE_UI_NO_INFRA_INSTANTIATION, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testApplicationLayerDoesNotInstantiateInfrastructureOrRepositoryImplementationsDirectly(): void
|
||||
{
|
||||
foreach ($this->applicationFiles() as $file) {
|
||||
$violations = $this->collectInstantiationViolations(
|
||||
$file,
|
||||
self::INFRASTRUCTURE_INSTANTIATION_FRAGMENTS,
|
||||
$this->isInfrastructureOrRepositoryClass(...),
|
||||
);
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
$this->fileRuleMessage(self::MESSAGE_APPLICATION_NO_INFRA_INSTANTIATION, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
tests/Architecture/KernelStructureTest.php
Normal file
54
tests/Architecture/KernelStructureTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class KernelStructureTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testKernelUsesOnlyApprovedTopLevelEntries(): void
|
||||
{
|
||||
$sharedPath = $this->projectPath('src/Kernel');
|
||||
self::assertDirectoryExists($sharedPath);
|
||||
|
||||
$entries = array_values(array_filter(scandir($sharedPath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame([
|
||||
'Html',
|
||||
'Http',
|
||||
'Mail',
|
||||
'Pagination',
|
||||
'Persistence',
|
||||
'Runtime',
|
||||
'Support',
|
||||
], $entries, 'Kernel must expose only approved transverse modules at top level.');
|
||||
}
|
||||
|
||||
public function testKernelPathsDoNotUseFeatureModuleNames(): void
|
||||
{
|
||||
$sharedPath = $this->projectPath('src/Kernel');
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($sharedPath, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST,
|
||||
);
|
||||
|
||||
foreach ($iterator as $fileInfo) {
|
||||
$relativePath = substr($fileInfo->getPathname(), strlen($sharedPath) + 1);
|
||||
$pathSegments = preg_split('~/+~', $relativePath) ?: [];
|
||||
|
||||
foreach ($pathSegments as $segment) {
|
||||
$name = pathinfo($segment, PATHINFO_FILENAME);
|
||||
|
||||
foreach (self::FEATURE_MODULES as $module) {
|
||||
self::assertFalse(
|
||||
str_starts_with($name, $module),
|
||||
sprintf('Kernel should not host feature-specific files or directories: %s', 'src/Kernel/' . $relativePath),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
67
tests/Architecture/LayerDependencyTest.php
Normal file
67
tests/Architecture/LayerDependencyTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class LayerDependencyTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testDomainLayerDoesNotDependOnApplicationInfrastructureOrUi(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->domainFiles(),
|
||||
array_merge(
|
||||
self::APPLICATION_NAMESPACES,
|
||||
self::INFRASTRUCTURE_NAMESPACES,
|
||||
self::UI_NAMESPACES,
|
||||
self::FRAMEWORK_NAMESPACE_FRAGMENTS,
|
||||
self::HTTP_MESSAGE_DEPENDENCIES,
|
||||
),
|
||||
self::MESSAGE_DOMAIN_FRAMEWORK_AGNOSTIC,
|
||||
);
|
||||
}
|
||||
|
||||
public function testApplicationLayerDoesNotDependOnUiInfrastructureOrHttpUploadedFiles(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->applicationFiles(),
|
||||
array_merge(
|
||||
$this->applicationMustNotDependOnInfrastructureFragments(),
|
||||
self::UI_NAMESPACES,
|
||||
self::APPLICATION_RUNTIME_DETAIL_FRAGMENTS,
|
||||
self::HTTP_MESSAGE_DEPENDENCIES,
|
||||
self::FRAMEWORK_NAMESPACE_FRAGMENTS,
|
||||
),
|
||||
self::MESSAGE_APPLICATION_PORTS_ONLY,
|
||||
);
|
||||
}
|
||||
|
||||
public function testApplicationLayerDoesNotDependOnInfrastructureImplementations(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->applicationFiles(),
|
||||
$this->applicationMustNotDependOnInfrastructureFragments(),
|
||||
self::MESSAGE_APPLICATION_NO_INFRA_DEPENDENCY,
|
||||
);
|
||||
}
|
||||
|
||||
public function testUiLayerDoesNotDependOnInfrastructureImplementations(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->uiFiles(),
|
||||
$this->uiMustNotDependOnInfrastructureFragments(),
|
||||
self::MESSAGE_UI_NO_INFRA_DEPENDENCY,
|
||||
);
|
||||
}
|
||||
|
||||
public function testInfrastructureLayerDoesNotDependOnUi(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->nonWiringInfrastructureFiles(),
|
||||
$this->infrastructureMustNotDependOnUiFragments(),
|
||||
self::MESSAGE_INFRA_NO_UI_DEPENDENCY,
|
||||
);
|
||||
}
|
||||
}
|
||||
112
tests/Architecture/ModuleBoundaryGovernanceTest.php
Normal file
112
tests/Architecture/ModuleBoundaryGovernanceTest.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ModuleBoundaryGovernanceTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testKernelDoesNotImportFeatureModules(): void
|
||||
{
|
||||
$violations = [];
|
||||
$featurePrefixes = [
|
||||
'Netig\\Netslim\\Identity\\',
|
||||
'Netig\\Netslim\\Settings\\',
|
||||
'Netig\\Netslim\\AuditLog\\',
|
||||
'Netig\\Netslim\\Notifications\\',
|
||||
'Netig\\Netslim\\Taxonomy\\',
|
||||
'Netig\\Netslim\\Media\\',
|
||||
];
|
||||
|
||||
foreach ($this->phpFilesUnder('src/Kernel') as $file) {
|
||||
foreach ($this->importedClasses($file) as $import) {
|
||||
$importsFeatureModule = false;
|
||||
foreach ($featurePrefixes as $prefix) {
|
||||
if (str_starts_with($import, $prefix)) {
|
||||
$importsFeatureModule = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$importsFeatureModule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = $this->relativePath($file) . ' -> ' . $import;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
'Kernel must not import feature modules directly',
|
||||
);
|
||||
}
|
||||
|
||||
public function testApplicationDomainAndInfrastructureUseOnlyApprovedCrossModuleContracts(): void
|
||||
{
|
||||
$violations = [];
|
||||
$allowedExternalImports = [
|
||||
'Identity' => [],
|
||||
'Settings' => [],
|
||||
'AuditLog' => [],
|
||||
'Notifications' => [],
|
||||
'Taxonomy' => [],
|
||||
'Media' => [],
|
||||
];
|
||||
|
||||
foreach (self::FEATURE_MODULES as $module) {
|
||||
$directories = [
|
||||
$this->projectPath('src/' . $module . '/Application'),
|
||||
$this->projectPath('src/' . $module . '/Domain'),
|
||||
$this->projectPath('src/' . $module . '/Infrastructure'),
|
||||
];
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory));
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $fileInfo->getPathname();
|
||||
$imports = $this->importedClasses($file);
|
||||
|
||||
foreach ($imports as $import) {
|
||||
if (!str_starts_with($import, 'Netig\\Netslim\\')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($import, 'Netig\\Netslim\\Kernel\\') || str_starts_with($import, 'Netig\\Netslim\\' . $module . '\\')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isAllowed = false;
|
||||
foreach ($allowedExternalImports[$module] as $allowedPrefix) {
|
||||
if (str_starts_with($import, $allowedPrefix)) {
|
||||
$isAllowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isAllowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = $this->relativePath($file) . ' -> ' . $import;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
'Application, Domain and Infrastructure layers must only cross module boundaries through approved public contracts',
|
||||
);
|
||||
}
|
||||
}
|
||||
34
tests/Architecture/ModuleStructureTest.php
Normal file
34
tests/Architecture/ModuleStructureTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ModuleStructureTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testFeatureModulesUseExpectedTopLevelStructure(): void
|
||||
{
|
||||
foreach (self::FEATURE_MODULES as $module) {
|
||||
$modulePath = $this->modulePath($module);
|
||||
self::assertDirectoryExists($modulePath, $this->moduleRuleMessage(self::MESSAGE_MODULE_DIRECTORY_MISSING, $module));
|
||||
|
||||
$entries = array_values(array_filter(scandir($modulePath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame($this->expectedModuleEntries($module), $entries, $this->moduleRuleMessage(self::MESSAGE_STANDARD_MODULE_STRUCTURE, $module));
|
||||
}
|
||||
}
|
||||
|
||||
public function testLegacyGlobalViewsDirectoryHasBeenRemoved(): void
|
||||
{
|
||||
self::assertDirectoryDoesNotExist($this->projectPath('views'));
|
||||
}
|
||||
|
||||
public function testFixtureApplicationExposesAModuleManifest(): void
|
||||
{
|
||||
self::assertDirectoryExists($this->projectPath('tests/Fixtures/Application/config'));
|
||||
self::assertFileExists($this->projectPath('tests/Fixtures/Application/config/modules.php'));
|
||||
}
|
||||
}
|
||||
338
tests/Architecture/Support/ArchitectureTestCase.php
Normal file
338
tests/Architecture/Support/ArchitectureTestCase.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture\Support;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
abstract class ArchitectureTestCase extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
protected const FEATURE_MODULES = ['Identity', 'Settings', 'AuditLog', 'Notifications', 'Taxonomy', 'Media'];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const APPLICATION_NAMESPACES = [
|
||||
'Netig\\Netslim\\Identity\\Application\\',
|
||||
'Netig\\Netslim\\Settings\\Application\\',
|
||||
'Netig\\Netslim\\AuditLog\\Application\\',
|
||||
'Netig\\Netslim\\Notifications\\Application\\',
|
||||
'Netig\\Netslim\\Taxonomy\\Application\\',
|
||||
'Netig\\Netslim\\Media\\Application\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const INFRASTRUCTURE_NAMESPACES = [
|
||||
'Netig\\Netslim\\Identity\\Infrastructure\\',
|
||||
'Netig\\Netslim\\Settings\\Infrastructure\\',
|
||||
'Netig\\Netslim\\AuditLog\\Infrastructure\\',
|
||||
'Netig\\Netslim\\Notifications\\Infrastructure\\',
|
||||
'Netig\\Netslim\\Taxonomy\\Infrastructure\\',
|
||||
'Netig\\Netslim\\Media\\Infrastructure\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const UI_NAMESPACES = [
|
||||
'Netig\\Netslim\\Identity\\UI\\',
|
||||
'Netig\\Netslim\\Taxonomy\\UI\\',
|
||||
'Netig\\Netslim\\Media\\UI\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const FRAMEWORK_NAMESPACE_FRAGMENTS = [
|
||||
'Slim\\',
|
||||
'Twig\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const HTTP_MESSAGE_DEPENDENCIES = [
|
||||
'Psr\\Http\\Message\\ServerRequestInterface',
|
||||
'Psr\\Http\\Message\\ResponseInterface',
|
||||
'Psr\\Http\\Message\\UploadedFileInterface',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const APPLICATION_RUNTIME_DETAIL_FRAGMENTS = [
|
||||
'Netig\\Netslim\\Kernel\\Http\\Application\\Session\\SessionManagerInterface',
|
||||
'usePDO;',
|
||||
'newPDO(',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const USE_CASE_OR_POLICY_FRAGMENTS = [
|
||||
'\\Application\\UseCase\\',
|
||||
'\\Domain\\Policy\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const APPLICATION_OR_DOMAIN_FRAGMENTS = [
|
||||
'\\Application\\',
|
||||
'\\Domain\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const INFRASTRUCTURE_INSTANTIATION_FRAGMENTS = [
|
||||
'\\Infrastructure\\',
|
||||
];
|
||||
|
||||
protected const MESSAGE_DOMAIN_FRAMEWORK_AGNOSTIC = 'Domain layer must stay framework-agnostic';
|
||||
protected const MESSAGE_APPLICATION_PORTS_ONLY = 'Application layer must depend on ports, not framework/runtime details';
|
||||
protected const MESSAGE_APPLICATION_NO_INFRA_DEPENDENCY = 'Application layer should depend on ports and injected services, not infrastructure implementations';
|
||||
protected const MESSAGE_UI_NO_INFRA_DEPENDENCY = 'UI layer should talk to interfaces and request adapters, not infrastructure implementations';
|
||||
protected const MESSAGE_INFRA_NO_UI_DEPENDENCY = 'Infrastructure implementation must not depend on UI classes';
|
||||
protected const MESSAGE_APPLICATION_SERVICE_DI_ONLY = 'Application services must rely on DI for use cases and policies, not instantiate them directly';
|
||||
protected const MESSAGE_CONTROLLER_DI_ONLY = 'Controllers must rely on DI and request adapters for application/domain dependencies, not instantiate them directly';
|
||||
protected const MESSAGE_UI_NO_INFRA_INSTANTIATION = 'UI layer must rely on DI and ports, not instantiate infrastructure or repository implementations directly';
|
||||
protected const MESSAGE_APPLICATION_NO_INFRA_INSTANTIATION = 'Application layer must rely on interfaces and injected collaborators, not instantiate infrastructure or repository implementations directly';
|
||||
protected const MESSAGE_WIRING_ONLY_UI_REFERENCE = 'Only infrastructure wiring files may reference UI classes';
|
||||
protected const MESSAGE_STANDARD_MODULE_STRUCTURE = '%s should only contain the standard modular structure';
|
||||
protected const MESSAGE_MODULE_DIRECTORY_MISSING = 'Module directory missing for %s';
|
||||
protected const MESSAGE_NO_ARCHITECTURE_VIOLATIONS = '%s';
|
||||
|
||||
/** @return list<string> */
|
||||
protected function domainFiles(): array
|
||||
{
|
||||
return $this->phpFilesUnder('src/*/Domain');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function applicationFiles(): array
|
||||
{
|
||||
return $this->phpFilesUnder('src/*/Application');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function applicationServiceFiles(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->applicationFiles(),
|
||||
static fn (string $file): bool => str_ends_with($file, 'ApplicationService.php'),
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function uiFiles(): array
|
||||
{
|
||||
return $this->phpFilesUnder('src/*/UI');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function uiControllerFiles(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->phpFilesUnder('src/*/UI/Http'),
|
||||
static fn (string $file): bool => str_ends_with($file, 'Controller.php'),
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function infrastructureFiles(): array
|
||||
{
|
||||
return $this->phpFilesUnder('src/*/Infrastructure');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function nonWiringInfrastructureFiles(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->infrastructureFiles(),
|
||||
static fn (string $file): bool => basename($file) !== 'dependencies.php',
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function phpFilesUnder(string $relativeDirectory): array
|
||||
{
|
||||
$directories = glob($this->projectPath($relativeDirectory), GLOB_ONLYDIR) ?: [];
|
||||
$files = [];
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory));
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = $fileInfo->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return array_values(array_unique($files));
|
||||
}
|
||||
|
||||
protected function projectPath(string $suffix = ''): string
|
||||
{
|
||||
$root = dirname(__DIR__, 3);
|
||||
|
||||
return $suffix === '' ? $root : $root . '/' . $suffix;
|
||||
}
|
||||
|
||||
protected function relativePath(string $absolutePath): string
|
||||
{
|
||||
return substr($absolutePath, strlen($this->projectPath()) + 1);
|
||||
}
|
||||
|
||||
protected function modulePath(string $module): string
|
||||
{
|
||||
return $this->projectPath('src/' . $module);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function expectedModuleEntries(string $module): array
|
||||
{
|
||||
$entries = match ($module) {
|
||||
'Taxonomy' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'TaxonomyModule.php', 'UI'],
|
||||
'Media' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'MediaModule.php', 'Migrations', 'UI'],
|
||||
'Settings' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'SettingsModule.php'],
|
||||
'AuditLog' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'AuditLogModule.php', 'Migrations'],
|
||||
'Notifications' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'NotificationsModule.php'],
|
||||
default => ['Application', 'Domain', 'Infrastructure', 'Migrations', 'UI', $module . 'Module.php'],
|
||||
};
|
||||
sort($entries);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
protected function normalizedCode(string $file): string
|
||||
{
|
||||
$code = file_get_contents($file);
|
||||
self::assertNotFalse($code, 'Unable to read ' . $file);
|
||||
|
||||
$normalized = '';
|
||||
foreach (token_get_all($code) as $token) {
|
||||
if (is_array($token)) {
|
||||
if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT, T_WHITESPACE], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized .= $token[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized .= $token;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param list<string> $needles */
|
||||
protected function containsAny(string $haystack, array $needles): bool
|
||||
{
|
||||
foreach ($needles as $needle) {
|
||||
if (str_contains($haystack, $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
* @param list<string> $forbiddenFragments
|
||||
*/
|
||||
protected function assertFilesDoNotReferenceFragments(array $files, array $forbiddenFragments, string $message): void
|
||||
{
|
||||
foreach ($files as $file) {
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertFalse(
|
||||
$this->containsAny($code, $forbiddenFragments),
|
||||
$this->fileRuleMessage($message, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function importedClasses(string $file): array
|
||||
{
|
||||
$code = file_get_contents($file);
|
||||
self::assertNotFalse($code, 'Unable to read ' . $file);
|
||||
|
||||
preg_match_all('/^use\s+([^;]+);/m', $code, $matches);
|
||||
|
||||
return array_values(array_unique(array_map('trim', $matches[1] ?? [])));
|
||||
}
|
||||
|
||||
protected function applicationMustNotDependOnInfrastructureFragments(): array
|
||||
{
|
||||
return array_filter(array_map(
|
||||
static fn (string $namespace): string => str_replace('\\\\', '\\', $namespace),
|
||||
self::INFRASTRUCTURE_NAMESPACES,
|
||||
));
|
||||
}
|
||||
|
||||
protected function uiMustNotDependOnInfrastructureFragments(): array
|
||||
{
|
||||
return $this->applicationMustNotDependOnInfrastructureFragments();
|
||||
}
|
||||
|
||||
protected function infrastructureMustNotDependOnUiFragments(): array
|
||||
{
|
||||
return array_filter(array_map(
|
||||
static fn (string $namespace): string => str_replace('\\\\', '\\', $namespace),
|
||||
self::UI_NAMESPACES,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $namespaceFragments
|
||||
* @param callable(string): bool $classFilter
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function collectInstantiationViolations(string $file, array $namespaceFragments, callable $classFilter): array
|
||||
{
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
if (!str_contains($code, 'new')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$violations = [];
|
||||
foreach ($this->importedClasses($file) as $import) {
|
||||
if (!$classFilter($import) || !$this->containsAny($import, $namespaceFragments)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$shortName = substr($import, (int) strrpos($import, '\\') + 1);
|
||||
if (str_contains($code, 'new' . $shortName . '(') || str_contains($code, 'new\\' . $import . '(')) {
|
||||
$violations[] = $this->relativePath($file) . ' instantiates ' . $import;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($violations));
|
||||
}
|
||||
|
||||
protected function isUseCaseOrPolicyClass(string $class): bool
|
||||
{
|
||||
return str_contains($class, '\\Application\\UseCase\\') || str_contains($class, '\\Domain\\Policy\\');
|
||||
}
|
||||
|
||||
protected function isApplicationOrDomainClass(string $class): bool
|
||||
{
|
||||
return str_contains($class, '\\Application\\') || str_contains($class, '\\Domain\\');
|
||||
}
|
||||
|
||||
protected function isInfrastructureOrRepositoryClass(string $class): bool
|
||||
{
|
||||
return str_contains($class, '\\Infrastructure\\') || str_contains($class, '\\Repository\\');
|
||||
}
|
||||
|
||||
protected function assertNoArchitectureViolations(array $violations, string $message): void
|
||||
{
|
||||
self::assertSame([], $violations, sprintf(self::MESSAGE_NO_ARCHITECTURE_VIOLATIONS, $message . ($violations !== [] ? "\n- " . implode("\n- ", $violations) : '')));
|
||||
}
|
||||
|
||||
protected function fileRuleMessage(string $message, string $file): string
|
||||
{
|
||||
return sprintf('%s: %s', $message, $this->relativePath($file));
|
||||
}
|
||||
|
||||
protected function moduleRuleMessage(string $message, string $module): string
|
||||
{
|
||||
return sprintf($message, $module);
|
||||
}
|
||||
}
|
||||
90
tests/Architecture/SupportGovernanceTest.php
Normal file
90
tests/Architecture/SupportGovernanceTest.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class SupportGovernanceTest extends ArchitectureTestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const APPROVED_SUPPORT_TOP_LEVEL_ENTRIES = [
|
||||
'Exception',
|
||||
'Util',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const APPROVED_SUPPORT_FILES = [
|
||||
'Exception/NotFoundException.php',
|
||||
'Util/DateParser.php',
|
||||
'Util/SlugHelper.php',
|
||||
];
|
||||
|
||||
public function testSupportUsesOnlyApprovedTopLevelEntries(): void
|
||||
{
|
||||
$supportPath = $this->projectPath('src/Kernel/Support');
|
||||
self::assertDirectoryExists($supportPath);
|
||||
|
||||
$entries = array_values(array_filter(scandir($supportPath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame(
|
||||
self::APPROVED_SUPPORT_TOP_LEVEL_ENTRIES,
|
||||
$entries,
|
||||
'Kernel/Support is frozen by default: only explicitly approved subdirectories may exist.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSupportContainsOnlyExplicitlyApprovedFiles(): void
|
||||
{
|
||||
$supportPath = $this->projectPath('src/Kernel/Support');
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($supportPath, \FilesystemIterator::SKIP_DOTS),
|
||||
);
|
||||
|
||||
$files = [];
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = substr($fileInfo->getPathname(), strlen($supportPath) + 1);
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
self::assertSame(
|
||||
self::APPROVED_SUPPORT_FILES,
|
||||
$files,
|
||||
'Any new file under Kernel/Support must be a deliberate architectural decision: update the allow list, tests and documentation together.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSupportStaysIndependentFromFeatureModulesAndFrameworks(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->phpFilesUnder('src/Kernel/Support'),
|
||||
array_merge(
|
||||
self::APPLICATION_NAMESPACES,
|
||||
self::INFRASTRUCTURE_NAMESPACES,
|
||||
self::UI_NAMESPACES,
|
||||
self::FRAMEWORK_NAMESPACE_FRAGMENTS,
|
||||
self::HTTP_MESSAGE_DEPENDENCIES,
|
||||
),
|
||||
'Kernel/Support must stay pure, transverse and framework-agnostic',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSupportClassesAreFinal(): void
|
||||
{
|
||||
foreach ($this->phpFilesUnder('src/Kernel/Support') as $file) {
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertTrue(
|
||||
str_contains($code, 'finalclass'),
|
||||
$this->fileRuleMessage('Kernel/Support classes must be final to avoid becoming extension points by accident', $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
tests/Architecture/WiringBoundaryTest.php
Normal file
27
tests/Architecture/WiringBoundaryTest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class WiringBoundaryTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testOnlyInfrastructureWiringFilesMayReferenceUiLayer(): void
|
||||
{
|
||||
foreach ($this->infrastructureFiles() as $file) {
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
if (!$this->containsAny($code, self::UI_NAMESPACES)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::assertSame(
|
||||
'dependencies.php',
|
||||
basename($file),
|
||||
$this->fileRuleMessage(self::MESSAGE_WIRING_ONLY_UI_REFERENCE, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
tests/AuditLog/AuditLogServiceTest.php
Normal file
39
tests/AuditLog/AuditLogServiceTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\AuditLog;
|
||||
|
||||
use Netig\Netslim\AuditLog\Application\AuditLogApplicationService;
|
||||
use Netig\Netslim\AuditLog\Infrastructure\PdoAuditLogRepository;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AuditLogServiceTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
private AuditLogApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$this->db->exec('CREATE TABLE audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, actor_user_id INTEGER DEFAULT NULL, context_json TEXT DEFAULT NULL, created_at TEXT NOT NULL)');
|
||||
$this->service = new AuditLogApplicationService(new PdoAuditLogRepository($this->db));
|
||||
}
|
||||
|
||||
public function testRecordsAndReadsAuditEntries(): void
|
||||
{
|
||||
$this->service->record('taxonomy.created', 'taxon', '12', 7, ['name' => 'PHP']);
|
||||
$entries = $this->service->listRecent();
|
||||
|
||||
self::assertCount(1, $entries);
|
||||
self::assertSame('taxonomy.created', $entries[0]->action);
|
||||
self::assertSame('taxon', $entries[0]->resourceType);
|
||||
self::assertSame('12', $entries[0]->resourceId);
|
||||
self::assertSame(['name' => 'PHP'], $entries[0]->context);
|
||||
}
|
||||
}
|
||||
96
tests/ControllerTestBase.php
Normal file
96
tests/ControllerTestBase.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
use Slim\Psr7\Response;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
abstract class ControllerTestBase extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $queryParams
|
||||
* @param array<string, mixed> $serverParams
|
||||
*/
|
||||
protected function makeGet(string $path, array $queryParams = [], array $serverParams = []): \Psr\Http\Message\ServerRequestInterface
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', $path, $serverParams);
|
||||
|
||||
if ($queryParams !== []) {
|
||||
$request = $request->withQueryParams($queryParams);
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parsedBody
|
||||
* @param array<string, mixed> $serverParams
|
||||
*/
|
||||
protected function makePost(string $path, array $parsedBody = [], array $serverParams = []): \Psr\Http\Message\ServerRequestInterface
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('POST', $path, $serverParams);
|
||||
|
||||
if ($parsedBody !== []) {
|
||||
$request = $request->withParsedBody($parsedBody);
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
protected function makeResponse(): ResponseInterface
|
||||
{
|
||||
return new Response();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Twig&\PHPUnit\Framework\MockObject\MockObject
|
||||
*/
|
||||
protected function makeTwigMock(): Twig
|
||||
{
|
||||
/** @var Twig&\PHPUnit\Framework\MockObject\MockObject $mock */
|
||||
$mock = $this->getMockBuilder(Twig::class)
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods(['render'])
|
||||
->getMock();
|
||||
|
||||
return $mock;
|
||||
}
|
||||
|
||||
protected function assertRedirectTo(ResponseInterface $response, string $path, int $status = 302): void
|
||||
{
|
||||
self::assertSame($status, $response->getStatusCode());
|
||||
self::assertSame($path, $response->getHeaderLine('Location'));
|
||||
}
|
||||
|
||||
protected function assertStatus(ResponseInterface $response, int $status): void
|
||||
{
|
||||
self::assertSame($status, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $expectedSubset
|
||||
*/
|
||||
protected function assertJsonContains(ResponseInterface $response, array $expectedSubset): void
|
||||
{
|
||||
$body = (string) $response->getBody();
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
self::assertIsArray($decoded, 'Response body is not valid JSON.');
|
||||
|
||||
foreach ($expectedSubset as $key => $value) {
|
||||
self::assertArrayHasKey($key, $decoded);
|
||||
self::assertSame($value, $decoded[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertJsonContentType(ResponseInterface $response): void
|
||||
{
|
||||
self::assertStringContainsString('application/json', $response->getHeaderLine('Content-Type'));
|
||||
}
|
||||
}
|
||||
21
tests/Fixtures/Application/config/modules.php
Normal file
21
tests/Fixtures/Application/config/modules.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Netig\Netslim\AuditLog\AuditLogModule;
|
||||
use Netig\Netslim\Identity\IdentityModule;
|
||||
use Netig\Netslim\Kernel\Runtime\KernelModule;
|
||||
use Netig\Netslim\Media\MediaModule;
|
||||
use Netig\Netslim\Notifications\NotificationsModule;
|
||||
use Netig\Netslim\Settings\SettingsModule;
|
||||
use Netig\Netslim\Taxonomy\TaxonomyModule;
|
||||
|
||||
return [
|
||||
KernelModule::class,
|
||||
IdentityModule::class,
|
||||
SettingsModule::class,
|
||||
AuditLogModule::class,
|
||||
NotificationsModule::class,
|
||||
TaxonomyModule::class,
|
||||
MediaModule::class,
|
||||
];
|
||||
1
tests/Fixtures/Application/templates/Kernel/layout.twig
Normal file
1
tests/Fixtures/Application/templates/Kernel/layout.twig
Normal file
@@ -0,0 +1 @@
|
||||
{% block content %}{% endblock %}
|
||||
209
tests/Identity/AccountControllerTest.php
Normal file
209
tests/Identity/AccountControllerTest.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\AuthServiceInterface;
|
||||
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
|
||||
use Netig\Netslim\Identity\UI\Http\AccountController;
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AccountController.
|
||||
*
|
||||
* Couvre showChangePassword() et changePassword() :
|
||||
* mots de passe non identiques, mot de passe faible, mot de passe actuel
|
||||
* incorrect, erreur inattendue et succès.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AccountControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
/** @var AuthServiceInterface&MockObject */
|
||||
private AuthServiceInterface $authService;
|
||||
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
/** @var SessionManagerInterface&MockObject */
|
||||
private SessionManagerInterface $sessionManager;
|
||||
|
||||
private AccountController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->authService = $this->createMock(AuthServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
|
||||
$this->controller = new AccountController(
|
||||
$this->view,
|
||||
$this->authService,
|
||||
$this->flash,
|
||||
$this->sessionManager,
|
||||
);
|
||||
}
|
||||
|
||||
// ── showChangePassword ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* showChangePassword() doit rendre le formulaire de changement de mot de passe.
|
||||
*/
|
||||
public function testShowChangePasswordRendersForm(): void
|
||||
{
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Identity/account/password-change.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->showChangePassword($this->makeGet('/account/password'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── changePassword ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* changePassword() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
|
||||
*/
|
||||
public function testChangePasswordRedirectsWhenPasswordMismatch(): void
|
||||
{
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_error', 'Les mots de passe ne correspondent pas');
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'oldpass',
|
||||
'new_password' => 'newpass1',
|
||||
'new_password_confirm' => 'newpass2',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() ne doit pas appeler authService si les mots de passe ne correspondent pas.
|
||||
*/
|
||||
public function testChangePasswordDoesNotCallServiceOnMismatch(): void
|
||||
{
|
||||
$this->authService->expects($this->never())->method('changePassword');
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'old',
|
||||
'new_password' => 'aaa',
|
||||
'new_password_confirm' => 'bbb',
|
||||
]);
|
||||
$this->controller->changePassword($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit afficher une erreur si le nouveau mot de passe est trop court.
|
||||
*/
|
||||
public function testChangePasswordRedirectsOnWeakPassword(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
$this->authService->method('changePassword')->willThrowException(new WeakPasswordException());
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_error', $this->stringContains('12 caractères'));
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'old',
|
||||
'new_password' => 'short',
|
||||
'new_password_confirm' => 'short',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit afficher une erreur si le mot de passe actuel est incorrect.
|
||||
*/
|
||||
public function testChangePasswordRedirectsOnWrongCurrentPassword(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
$this->authService->method('changePassword')
|
||||
->willThrowException(new \InvalidArgumentException('Mot de passe actuel incorrect'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_error', 'Le mot de passe actuel est incorrect');
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'wrong',
|
||||
'new_password' => 'newpassword',
|
||||
'new_password_confirm' => 'newpassword',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit afficher une erreur générique en cas d'exception inattendue.
|
||||
*/
|
||||
public function testChangePasswordRedirectsOnUnexpectedError(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
$this->authService->method('changePassword')
|
||||
->willThrowException(new \RuntimeException('DB error'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_error', $this->stringContains('inattendue'));
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'old',
|
||||
'new_password' => 'newpassword',
|
||||
'new_password_confirm' => 'newpassword',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit afficher un message de succès et rediriger en cas de succès.
|
||||
*/
|
||||
public function testChangePasswordRedirectsWithSuccessFlash(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('password_success', $this->stringContains('Mot de passe modifié'));
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'oldpass123',
|
||||
'new_password' => 'newpass123',
|
||||
'new_password_confirm' => 'newpass123',
|
||||
]);
|
||||
$res = $this->controller->changePassword($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/account/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit utiliser 0 comme userId de repli si la session est vide.
|
||||
*/
|
||||
public function testChangePasswordUsesZeroAsUserIdFallback(): void
|
||||
{
|
||||
$this->sessionManager->method('getUserId')->willReturn(null);
|
||||
|
||||
$this->authService->expects($this->once())
|
||||
->method('changePassword')
|
||||
->with(0, $this->anything(), $this->anything());
|
||||
|
||||
$req = $this->makePost('/account/password', [
|
||||
'current_password' => 'old',
|
||||
'new_password' => 'newpassword',
|
||||
'new_password_confirm' => 'newpassword',
|
||||
]);
|
||||
$this->controller->changePassword($req, $this->makeResponse());
|
||||
}
|
||||
}
|
||||
95
tests/Identity/AdminDeleteUserTest.php
Normal file
95
tests/Identity/AdminDeleteUserTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\Command\AdminDeleteUserCommand;
|
||||
use Netig\Netslim\Identity\Application\UseCase\AdminDeleteUser;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Exception\CannotDeleteOwnAccountException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\ProtectedAdministratorDeletionException;
|
||||
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AdminDeleteUserTest extends TestCase
|
||||
{
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
private AdminDeleteUser $useCase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
|
||||
$this->useCase = new AdminDeleteUser($this->userRepository);
|
||||
}
|
||||
|
||||
public function testHandleThrowsNotFoundWhenTargetUserDoesNotExist(): void
|
||||
{
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(42)
|
||||
->willReturn(null);
|
||||
$this->userRepository->expects($this->never())->method('delete');
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
|
||||
$this->useCase->handle(new AdminDeleteUserCommand(1, 42));
|
||||
}
|
||||
|
||||
public function testHandleThrowsWhenTargetUserIsAdmin(): void
|
||||
{
|
||||
$admin = $this->makeUser(7, 'root', User::ROLE_ADMIN);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(7)
|
||||
->willReturn($admin);
|
||||
$this->userRepository->expects($this->never())->method('delete');
|
||||
|
||||
$this->expectException(ProtectedAdministratorDeletionException::class);
|
||||
|
||||
$this->useCase->handle(new AdminDeleteUserCommand(1, 7));
|
||||
}
|
||||
|
||||
public function testHandleThrowsWhenActorTriesToDeleteOwnAccount(): void
|
||||
{
|
||||
$user = $this->makeUser(5, 'alice', User::ROLE_EDITOR);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(5)
|
||||
->willReturn($user);
|
||||
$this->userRepository->expects($this->never())->method('delete');
|
||||
|
||||
$this->expectException(CannotDeleteOwnAccountException::class);
|
||||
|
||||
$this->useCase->handle(new AdminDeleteUserCommand(5, 5));
|
||||
}
|
||||
|
||||
public function testHandleDeletesUserAndReturnsTargetUserOnSuccess(): void
|
||||
{
|
||||
$user = $this->makeUser(9, 'charlie', User::ROLE_USER);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(9)
|
||||
->willReturn($user);
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('delete')
|
||||
->with(9);
|
||||
|
||||
$result = $this->useCase->handle(new AdminDeleteUserCommand(1, 9));
|
||||
|
||||
$this->assertSame($user, $result);
|
||||
}
|
||||
|
||||
private function makeUser(int $id, string $username, string $role): User
|
||||
{
|
||||
return new User($id, $username, sprintf('%s@example.com', $username), 'hashed-password', $role);
|
||||
}
|
||||
}
|
||||
40
tests/Identity/AdminHomePathTest.php
Normal file
40
tests/Identity/AdminHomePathTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\UI\Http\AdminHomePath;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AdminHomePathTest extends TestCase
|
||||
{
|
||||
private ?string $previous = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->previous = $_ENV['ADMIN_HOME_PATH'] ?? null;
|
||||
unset($_ENV['ADMIN_HOME_PATH']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->previous === null) {
|
||||
unset($_ENV['ADMIN_HOME_PATH']);
|
||||
} else {
|
||||
$_ENV['ADMIN_HOME_PATH'] = $this->previous;
|
||||
}
|
||||
}
|
||||
|
||||
public function testResolveDefaultsToGenericAdminPath(): void
|
||||
{
|
||||
self::assertSame('/admin', AdminHomePath::resolve());
|
||||
}
|
||||
|
||||
public function testResolveNormalizesConfiguredPath(): void
|
||||
{
|
||||
$_ENV['ADMIN_HOME_PATH'] = 'admin/posts';
|
||||
|
||||
self::assertSame('/admin/posts', AdminHomePath::resolve());
|
||||
}
|
||||
}
|
||||
112
tests/Identity/AdminUpdateUserRoleTest.php
Normal file
112
tests/Identity/AdminUpdateUserRoleTest.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\Command\AdminUpdateUserRoleCommand;
|
||||
use Netig\Netslim\Identity\Application\UseCase\AdminUpdateUserRole;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Exception\CannotModifyOwnRoleException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\ProtectedAdministratorRoleChangeException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\RoleAssignmentNotAllowedException;
|
||||
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
|
||||
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AdminUpdateUserRoleTest extends TestCase
|
||||
{
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
private AdminUpdateUserRole $useCase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
|
||||
$this->useCase = new AdminUpdateUserRole($this->userRepository, new RolePolicy());
|
||||
}
|
||||
|
||||
public function testHandleThrowsNotFoundWhenTargetUserDoesNotExist(): void
|
||||
{
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(42)
|
||||
->willReturn(null);
|
||||
$this->userRepository->expects($this->never())->method('updateRole');
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
|
||||
$this->useCase->handle(new AdminUpdateUserRoleCommand(1, 42, User::ROLE_EDITOR));
|
||||
}
|
||||
|
||||
public function testHandleThrowsWhenActorTriesToModifyOwnRole(): void
|
||||
{
|
||||
$user = $this->makeUser(5, 'alice', User::ROLE_USER);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(5)
|
||||
->willReturn($user);
|
||||
$this->userRepository->expects($this->never())->method('updateRole');
|
||||
|
||||
$this->expectException(CannotModifyOwnRoleException::class);
|
||||
|
||||
$this->useCase->handle(new AdminUpdateUserRoleCommand(5, 5, User::ROLE_EDITOR));
|
||||
}
|
||||
|
||||
public function testHandleThrowsWhenTargetUserIsAdmin(): void
|
||||
{
|
||||
$admin = $this->makeUser(7, 'root', User::ROLE_ADMIN);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(7)
|
||||
->willReturn($admin);
|
||||
$this->userRepository->expects($this->never())->method('updateRole');
|
||||
|
||||
$this->expectException(ProtectedAdministratorRoleChangeException::class);
|
||||
|
||||
$this->useCase->handle(new AdminUpdateUserRoleCommand(1, 7, User::ROLE_EDITOR));
|
||||
}
|
||||
|
||||
public function testHandleThrowsWhenRequestedRoleIsNotAssignable(): void
|
||||
{
|
||||
$user = $this->makeUser(8, 'bob', User::ROLE_USER);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(8)
|
||||
->willReturn($user);
|
||||
$this->userRepository->expects($this->never())->method('updateRole');
|
||||
|
||||
$this->expectException(RoleAssignmentNotAllowedException::class);
|
||||
|
||||
$this->useCase->handle(new AdminUpdateUserRoleCommand(1, 8, User::ROLE_ADMIN));
|
||||
}
|
||||
|
||||
public function testHandleUpdatesRoleAndReturnsTargetUserOnSuccess(): void
|
||||
{
|
||||
$user = $this->makeUser(9, 'charlie', User::ROLE_USER);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(9)
|
||||
->willReturn($user);
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('updateRole')
|
||||
->with(9, User::ROLE_EDITOR);
|
||||
|
||||
$result = $this->useCase->handle(new AdminUpdateUserRoleCommand(1, 9, User::ROLE_EDITOR));
|
||||
|
||||
$this->assertSame($user, $result);
|
||||
}
|
||||
|
||||
private function makeUser(int $id, string $username, string $role): User
|
||||
{
|
||||
return new User($id, $username, sprintf('%s@example.com', $username), 'hashed-password', $role);
|
||||
}
|
||||
}
|
||||
222
tests/Identity/AdminUserProvisionerTest.php
Normal file
222
tests/Identity/AdminUserProvisionerTest.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Infrastructure\AdminUserProvisioner;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AdminUserProvisioner.
|
||||
*
|
||||
* Vérifie que le provisionnement insère le compte administrateur quand il est absent,
|
||||
* et ne fait rien si le compte existe déjà (idempotence).
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler le provisionneur identité de la base de données.
|
||||
* Les variables d'environnement sont définies dans setUp() et restaurées dans tearDown().
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AdminUserProvisionerTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
/** @var array<string, string> Variables d'environnement sauvegardées avant chaque test */
|
||||
private array $envBackup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
|
||||
$this->envBackup = [
|
||||
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? '',
|
||||
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? '',
|
||||
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? '',
|
||||
];
|
||||
|
||||
$_ENV['ADMIN_USERNAME'] = 'admin';
|
||||
$_ENV['ADMIN_EMAIL'] = 'admin@example.com';
|
||||
$_ENV['ADMIN_PASSWORD'] = 'secret123456';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->envBackup as $key => $value) {
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée un PDOStatement mock retournant $fetchColumnValue pour fetchColumn().
|
||||
*/
|
||||
private function stmtReturning(mixed $fetchColumnValue): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetchColumn')->willReturn($fetchColumnValue);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
private function stmtForWrite(): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── seed() — admin absent ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* seed() doit insérer le compte admin quand aucun utilisateur
|
||||
* portant ce nom d'utilisateur n'existe en base.
|
||||
*/
|
||||
public function testSeedInsertsAdminWhenAbsent(): void
|
||||
{
|
||||
$selectStmt = $this->stmtReturning(false);
|
||||
$insertStmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->expects($this->exactly(2))
|
||||
->method('prepare')
|
||||
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
|
||||
|
||||
$insertStmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
return $data[':username'] === 'admin'
|
||||
&& $data[':email'] === 'admin@example.com'
|
||||
&& $data[':role'] === 'admin'
|
||||
&& isset($data[':password_hash'], $data[':created_at'])
|
||||
&& password_verify('secret123456', $data[':password_hash']);
|
||||
}));
|
||||
|
||||
AdminUserProvisioner::seed($this->db);
|
||||
}
|
||||
|
||||
/**
|
||||
* seed() doit normaliser le nom d'utilisateur en minuscules
|
||||
* et supprimer les espaces autour.
|
||||
*/
|
||||
public function testSeedNormalizesUsername(): void
|
||||
{
|
||||
$_ENV['ADMIN_USERNAME'] = ' ADMIN ';
|
||||
$_ENV['ADMIN_EMAIL'] = ' ADMIN@EXAMPLE.COM ';
|
||||
|
||||
$selectStmt = $this->stmtReturning(false);
|
||||
$insertStmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->method('prepare')
|
||||
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
|
||||
|
||||
$insertStmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
return $data[':username'] === 'admin'
|
||||
&& $data[':email'] === 'admin@example.com';
|
||||
}));
|
||||
|
||||
AdminUserProvisioner::seed($this->db);
|
||||
}
|
||||
|
||||
/**
|
||||
* seed() doit stocker un hash bcrypt, jamais le mot de passe en clair.
|
||||
*/
|
||||
public function testSeedHashesPasswordBeforeInsert(): void
|
||||
{
|
||||
$selectStmt = $this->stmtReturning(false);
|
||||
$insertStmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->method('prepare')
|
||||
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
|
||||
|
||||
$insertStmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
// Le hash ne doit pas être le mot de passe brut
|
||||
return $data[':password_hash'] !== 'secret123456'
|
||||
// Et doit être vérifiable avec password_verify
|
||||
&& password_verify('secret123456', $data[':password_hash']);
|
||||
}));
|
||||
|
||||
AdminUserProvisioner::seed($this->db);
|
||||
}
|
||||
|
||||
/**
|
||||
* seed() doit renseigner created_at au format 'Y-m-d H:i:s'.
|
||||
*/
|
||||
public function testSeedSetsCreatedAt(): void
|
||||
{
|
||||
$selectStmt = $this->stmtReturning(false);
|
||||
$insertStmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->method('prepare')
|
||||
->willReturnOnConsecutiveCalls($selectStmt, $insertStmt);
|
||||
|
||||
$insertStmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
return isset($data[':created_at'])
|
||||
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':created_at']);
|
||||
}));
|
||||
|
||||
AdminUserProvisioner::seed($this->db);
|
||||
}
|
||||
|
||||
|
||||
// ── seed() — admin présent (idempotence) ───────────────────────
|
||||
|
||||
/**
|
||||
* seed() ne doit pas exécuter d'INSERT si le compte admin existe déjà.
|
||||
*/
|
||||
public function testSeedDoesNotInsertWhenAdminExists(): void
|
||||
{
|
||||
// fetchColumn() retourne l'id existant — le compte est déjà là
|
||||
$selectStmt = $this->stmtReturning('1');
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->willReturn($selectStmt);
|
||||
|
||||
// prepare() ne doit être appelé qu'une fois (SELECT uniquement, pas d'INSERT)
|
||||
AdminUserProvisioner::seed($this->db);
|
||||
}
|
||||
|
||||
/**
|
||||
* seed() vérifie l'existence du compte via le nom d'utilisateur normalisé.
|
||||
*/
|
||||
public function testSeedChecksExistenceByNormalizedUsername(): void
|
||||
{
|
||||
$_ENV['ADMIN_USERNAME'] = ' Admin ';
|
||||
|
||||
$selectStmt = $this->stmtReturning('1');
|
||||
|
||||
$this->db->method('prepare')->willReturn($selectStmt);
|
||||
|
||||
$selectStmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':username' => 'admin']);
|
||||
|
||||
AdminUserProvisioner::seed($this->db);
|
||||
}
|
||||
|
||||
public function testSeedRejectsTooShortAdminPassword(): void
|
||||
{
|
||||
$_ENV['ADMIN_PASSWORD'] = 'short123';
|
||||
|
||||
$this->db->expects($this->never())
|
||||
->method('prepare');
|
||||
|
||||
$this->expectException(\Netig\Netslim\Identity\Domain\Exception\WeakPasswordException::class);
|
||||
$this->expectExceptionMessage('au moins 12 caractères');
|
||||
|
||||
AdminUserProvisioner::seed($this->db);
|
||||
}
|
||||
}
|
||||
179
tests/Identity/AuthControllerTest.php
Normal file
179
tests/Identity/AuthControllerTest.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\AuthServiceInterface;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\UI\Http\AuthController;
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AuthController.
|
||||
*
|
||||
* Couvre showLogin(), login() et logout() sans passer par le routeur Slim.
|
||||
* AuthService, FlashService et Twig sont mockés — aucune session PHP,
|
||||
* aucune base de données, aucun serveur HTTP n'est requis.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AuthControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
/** @var AuthServiceInterface&MockObject */
|
||||
private AuthServiceInterface $authService;
|
||||
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
private AuthController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->authService = $this->createMock(AuthServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
|
||||
$this->controller = new AuthController(
|
||||
$this->view,
|
||||
$this->authService,
|
||||
$this->flash,
|
||||
new ClientIpResolver(['*']),
|
||||
);
|
||||
}
|
||||
|
||||
// ── showLogin ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* showLogin() doit rediriger vers /admin si l'utilisateur est déjà connecté.
|
||||
*/
|
||||
public function testShowLoginRedirectsWhenAlreadyLoggedIn(): void
|
||||
{
|
||||
$this->authService->method('isLoggedIn')->willReturn(true);
|
||||
|
||||
$res = $this->controller->showLogin($this->makeGet('/auth/login'), $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* showLogin() doit rendre le formulaire de connexion si l'utilisateur n'est pas connecté.
|
||||
*/
|
||||
public function testShowLoginRendersFormWhenNotLoggedIn(): void
|
||||
{
|
||||
$this->authService->method('isLoggedIn')->willReturn(false);
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Identity/login.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->showLogin($this->makeGet('/auth/login'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── login ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* login() doit rediriger avec un message flash si l'IP est verrouillée.
|
||||
*/
|
||||
public function testLoginRedirectsWhenRateLimited(): void
|
||||
{
|
||||
$this->authService->method('checkRateLimit')->willReturn(5);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('login_error', $this->stringContains('Trop de tentatives'));
|
||||
|
||||
$req = $this->makePost('/auth/login', [], ['REMOTE_ADDR' => '10.0.0.1']);
|
||||
$res = $this->controller->login($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/auth/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* login() doit conjuguer correctement le singulier/pluriel dans le message de rate limit.
|
||||
*/
|
||||
public function testLoginRateLimitMessageIsSingularForOneMinute(): void
|
||||
{
|
||||
$this->authService->method('checkRateLimit')->willReturn(1);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('login_error', $this->logicalNot($this->stringContains('minutes')));
|
||||
|
||||
$req = $this->makePost('/auth/login', [], ['REMOTE_ADDR' => '10.0.0.1']);
|
||||
$this->controller->login($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* login() doit enregistrer l'échec et rediriger si les identifiants sont invalides.
|
||||
*/
|
||||
public function testLoginRecordsFailureOnInvalidCredentials(): void
|
||||
{
|
||||
$this->authService->method('checkRateLimit')->willReturn(0);
|
||||
$this->authService->method('authenticate')->willReturn(null);
|
||||
|
||||
$this->authService->expects($this->once())->method('recordFailure');
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('login_error', 'Identifiants invalides');
|
||||
|
||||
$req = $this->makePost('/auth/login', ['username' => 'alice', 'password' => 'wrong']);
|
||||
$res = $this->controller->login($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/auth/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* login() doit ouvrir la session et rediriger vers /admin en cas de succès.
|
||||
*/
|
||||
public function testLoginRedirectsToAdminOnSuccess(): void
|
||||
{
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
|
||||
$this->authService->method('checkRateLimit')->willReturn(0);
|
||||
$this->authService->method('authenticate')->willReturn($user);
|
||||
|
||||
$this->authService->expects($this->once())->method('resetRateLimit');
|
||||
$this->authService->expects($this->once())->method('login')->with($user);
|
||||
|
||||
$req = $this->makePost('/auth/login', ['username' => 'alice', 'password' => 'secret']);
|
||||
$res = $this->controller->login($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* login() doit utiliser '0.0.0.0' comme IP de repli si REMOTE_ADDR est absent.
|
||||
*/
|
||||
public function testLoginFallsBackToDefaultIpWhenRemoteAddrMissing(): void
|
||||
{
|
||||
$this->authService->method('checkRateLimit')->willReturn(0);
|
||||
$this->authService->method('authenticate')->willReturn(null);
|
||||
|
||||
$this->authService->expects($this->once())
|
||||
->method('checkRateLimit')
|
||||
->with('0.0.0.0');
|
||||
|
||||
$req = $this->makePost('/auth/login');
|
||||
$this->controller->login($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
// ── logout ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* logout() doit détruire la session et rediriger vers l'accueil.
|
||||
*/
|
||||
public function testLogoutDestroysSessionAndRedirects(): void
|
||||
{
|
||||
$this->authService->expects($this->once())->method('logout');
|
||||
|
||||
$res = $this->controller->logout($this->makePost('/auth/logout'), $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/');
|
||||
}
|
||||
}
|
||||
228
tests/Identity/AuthServiceRateLimitTest.php
Normal file
228
tests/Identity/AuthServiceRateLimitTest.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\AuthApplicationService;
|
||||
use Netig\Netslim\Identity\Application\AuthSessionInterface;
|
||||
use Netig\Netslim\Identity\Application\UseCase\AuthenticateUser;
|
||||
use Netig\Netslim\Identity\Application\UseCase\ChangePassword;
|
||||
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
|
||||
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour la protection anti-brute force de AuthApplicationService.
|
||||
*
|
||||
* Vérifie le comportement de checkRateLimit(), recordFailure() et
|
||||
* resetRateLimit(). Les constantes testées correspondent aux valeurs
|
||||
* définies dans AuthApplicationService :
|
||||
* - MAX_ATTEMPTS = 5 : nombre d'échecs avant verrouillage
|
||||
* - LOCK_MINUTES = 15 : durée du verrouillage en minutes
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AuthServiceRateLimitTest extends TestCase
|
||||
{
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/** @var AuthSessionInterface&MockObject */
|
||||
private AuthSessionInterface $authSession;
|
||||
|
||||
/** @var LoginAttemptRepositoryInterface&MockObject */
|
||||
private LoginAttemptRepositoryInterface $loginAttemptRepository;
|
||||
|
||||
private AuthApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
|
||||
$this->authSession = $this->createMock(AuthSessionInterface::class);
|
||||
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
|
||||
|
||||
$this->service = new AuthApplicationService(
|
||||
$this->authSession,
|
||||
$this->loginAttemptRepository,
|
||||
new LoginRateLimitPolicy(),
|
||||
new AuthenticateUser($this->userRepository, new PasswordPolicy()),
|
||||
new ChangePassword($this->userRepository, new PasswordPolicy()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── checkRateLimit ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner 0 si l'IP n'a aucune entrée en base.
|
||||
*/
|
||||
public function testCheckRateLimitUnknownIpReturnsZero(): void
|
||||
{
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn(null);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertSame(0, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner 0 si l'IP a des tentatives
|
||||
* mais n'est pas encore verrouillée (locked_until = null).
|
||||
*/
|
||||
public function testCheckRateLimitNoLockReturnsZero(): void
|
||||
{
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn([
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 3,
|
||||
'locked_until' => null,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertSame(0, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner le nombre de minutes restantes
|
||||
* si l'IP est actuellement verrouillée.
|
||||
*/
|
||||
public function testCheckRateLimitLockedIpReturnsRemainingMinutes(): void
|
||||
{
|
||||
$lockedUntil = date('Y-m-d H:i:s', time() + 10 * 60);
|
||||
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn([
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 5,
|
||||
'locked_until' => $lockedUntil,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertGreaterThan(0, $result);
|
||||
$this->assertLessThanOrEqual(15, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner au minimum 1 minute
|
||||
* même si le verrouillage expire dans quelques secondes.
|
||||
*/
|
||||
public function testCheckRateLimitReturnsAtLeastOneMinute(): void
|
||||
{
|
||||
$lockedUntil = date('Y-m-d H:i:s', time() + 30);
|
||||
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn([
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 5,
|
||||
'locked_until' => $lockedUntil,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertSame(1, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit retourner 0 si le verrouillage est expiré.
|
||||
*/
|
||||
public function testCheckRateLimitExpiredLockReturnsZero(): void
|
||||
{
|
||||
$lockedUntil = date('Y-m-d H:i:s', time() - 60);
|
||||
|
||||
$this->loginAttemptRepository->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn([
|
||||
'ip' => '192.168.1.1',
|
||||
'attempts' => 5,
|
||||
'locked_until' => $lockedUntil,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$result = $this->service->checkRateLimit('192.168.1.1');
|
||||
|
||||
$this->assertSame(0, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkRateLimit() doit toujours appeler deleteExpired() avant la vérification.
|
||||
*/
|
||||
public function testCheckRateLimitCallsDeleteExpired(): void
|
||||
{
|
||||
$this->loginAttemptRepository
|
||||
->expects($this->once())
|
||||
->method('deleteExpired');
|
||||
|
||||
$this->loginAttemptRepository->method('findByIp')->willReturn(null);
|
||||
|
||||
$this->service->checkRateLimit('192.168.1.1');
|
||||
}
|
||||
|
||||
|
||||
// ── recordFailure ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* recordFailure() doit déléguer avec MAX_ATTEMPTS = 5 et LOCK_MINUTES = 15.
|
||||
*/
|
||||
public function testRecordFailureDelegatesWithConstants(): void
|
||||
{
|
||||
$this->loginAttemptRepository
|
||||
->expects($this->once())
|
||||
->method('recordFailure')
|
||||
->with('192.168.1.1', 5, 15);
|
||||
|
||||
$this->service->recordFailure('192.168.1.1');
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit transmettre l'adresse IP exacte au dépôt.
|
||||
*/
|
||||
public function testRecordFailurePassesIpAddress(): void
|
||||
{
|
||||
$ip = '10.0.0.42';
|
||||
|
||||
$this->loginAttemptRepository
|
||||
->expects($this->once())
|
||||
->method('recordFailure')
|
||||
->with($ip, $this->anything(), $this->anything());
|
||||
|
||||
$this->service->recordFailure($ip);
|
||||
}
|
||||
|
||||
|
||||
// ── resetRateLimit ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* resetRateLimit() doit appeler resetForIp() avec l'adresse IP fournie.
|
||||
*/
|
||||
public function testResetRateLimitCallsResetForIp(): void
|
||||
{
|
||||
$ip = '192.168.1.1';
|
||||
|
||||
$this->loginAttemptRepository
|
||||
->expects($this->once())
|
||||
->method('resetForIp')
|
||||
->with($ip);
|
||||
|
||||
$this->service->resetRateLimit($ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* resetRateLimit() ne doit pas appeler recordFailure() ni deleteExpired().
|
||||
*/
|
||||
public function testResetRateLimitCallsNothingElse(): void
|
||||
{
|
||||
$this->loginAttemptRepository->expects($this->never())->method('recordFailure');
|
||||
$this->loginAttemptRepository->expects($this->never())->method('deleteExpired');
|
||||
$this->loginAttemptRepository->method('resetForIp');
|
||||
|
||||
$this->service->resetRateLimit('192.168.1.1');
|
||||
}
|
||||
}
|
||||
286
tests/Identity/AuthServiceTest.php
Normal file
286
tests/Identity/AuthServiceTest.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\AuthApplicationService;
|
||||
use Netig\Netslim\Identity\Application\AuthSessionInterface;
|
||||
use Netig\Netslim\Identity\Application\UseCase\AuthenticateUser;
|
||||
use Netig\Netslim\Identity\Application\UseCase\ChangePassword;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
|
||||
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
|
||||
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour AuthApplicationService.
|
||||
*
|
||||
* Vérifie l'authentification, le changement de mot de passe et la gestion
|
||||
* des sessions. La création de comptes est couverte par UserServiceTest.
|
||||
* Les dépendances sont remplacées par des mocks via leurs interfaces pour
|
||||
* isoler le service.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class AuthServiceTest extends TestCase
|
||||
{
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/** @var AuthSessionInterface&MockObject */
|
||||
private AuthSessionInterface $authSession;
|
||||
|
||||
/** @var LoginAttemptRepositoryInterface&MockObject */
|
||||
private LoginAttemptRepositoryInterface $loginAttemptRepository;
|
||||
|
||||
private AuthApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
|
||||
$this->authSession = $this->createMock(AuthSessionInterface::class);
|
||||
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
|
||||
|
||||
$this->service = new AuthApplicationService(
|
||||
$this->authSession,
|
||||
$this->loginAttemptRepository,
|
||||
new LoginRateLimitPolicy(),
|
||||
new AuthenticateUser($this->userRepository, new PasswordPolicy()),
|
||||
new ChangePassword($this->userRepository, new PasswordPolicy()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── authenticate ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* authenticate() doit retourner l'utilisateur si les identifiants sont corrects.
|
||||
*/
|
||||
public function testAuthenticateValidCredentials(): void
|
||||
{
|
||||
$password = 'motdepasse12';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password);
|
||||
|
||||
$this->userRepository->expects($this->once())->method('findByUsername')->with('alice')->willReturn($user);
|
||||
|
||||
$result = $this->service->authenticate('alice', $password);
|
||||
|
||||
$this->assertSame($user, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* authenticate() doit normaliser le nom d'utilisateur en minuscules.
|
||||
*/
|
||||
public function testAuthenticateNormalizesUsername(): void
|
||||
{
|
||||
$password = 'motdepasse12';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password);
|
||||
|
||||
$this->userRepository->expects($this->once())->method('findByUsername')->with('alice')->willReturn($user);
|
||||
|
||||
$result = $this->service->authenticate('ALICE', $password);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* authenticate() ne doit pas supprimer les espaces du mot de passe saisi.
|
||||
*/
|
||||
public function testAuthenticatePreservesPasswordWhitespace(): void
|
||||
{
|
||||
$password = ' motdepasse12 ';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password);
|
||||
|
||||
$this->userRepository->expects($this->exactly(2))->method('findByUsername')->with('alice')->willReturn($user);
|
||||
|
||||
$this->assertSame($user, $this->service->authenticate('alice', $password));
|
||||
$this->assertNull($this->service->authenticate('alice', 'motdepasse12'));
|
||||
}
|
||||
|
||||
/**
|
||||
* authenticate() doit retourner null si l'utilisateur est introuvable.
|
||||
*/
|
||||
public function testAuthenticateUnknownUser(): void
|
||||
{
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
|
||||
$result = $this->service->authenticate('inconnu', 'motdepasse12');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* authenticate() doit retourner null si le mot de passe est incorrect.
|
||||
*/
|
||||
public function testAuthenticateWrongPassword(): void
|
||||
{
|
||||
$user = $this->makeUser('alice', 'alice@example.com', 'bonmotdepasse');
|
||||
$this->userRepository->method('findByUsername')->willReturn($user);
|
||||
|
||||
$result = $this->service->authenticate('alice', 'mauvaismdp');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
|
||||
// ── changePassword ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* changePassword() doit mettre à jour le hash si les données sont valides.
|
||||
*/
|
||||
public function testChangePasswordWithValidData(): void
|
||||
{
|
||||
$password = 'ancienmdp12';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
|
||||
|
||||
$this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($user);
|
||||
$this->userRepository->expects($this->once())->method('updatePassword')->with(1);
|
||||
|
||||
$this->service->changePassword(1, $password, 'nouveaumdp12');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit lever une exception si le mot de passe actuel est incorrect.
|
||||
*/
|
||||
public function testChangePasswordWrongCurrentPassword(): void
|
||||
{
|
||||
$user = $this->makeUser('alice', 'alice@example.com', 'bonmotdepasse', 1);
|
||||
$this->userRepository->method('findById')->willReturn($user);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessageMatches('/actuel incorrect/');
|
||||
|
||||
$this->service->changePassword(1, 'mauvaismdp', 'nouveaumdp12');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit conserver exactement les espaces du nouveau mot de passe.
|
||||
*/
|
||||
public function testChangePasswordPreservesNewPasswordWhitespace(): void
|
||||
{
|
||||
$currentPassword = 'ancienmdp12';
|
||||
$newPassword = ' nouveaumdp12 ';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $currentPassword, 1);
|
||||
|
||||
$this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($user);
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('updatePassword')
|
||||
->with(1, $this->callback(static function (string $hash) use ($newPassword): bool {
|
||||
return password_verify($newPassword, $hash)
|
||||
&& !password_verify(trim($newPassword), $hash);
|
||||
}));
|
||||
|
||||
$this->service->changePassword(1, $currentPassword, $newPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() avec exactement 12 caractères doit réussir (frontière basse).
|
||||
*/
|
||||
public function testChangePasswordMinimumLengthNewPassword(): void
|
||||
{
|
||||
$password = 'ancienmdp12';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
|
||||
$this->userRepository->method('findById')->willReturn($user);
|
||||
$this->userRepository->expects($this->once())->method('updatePassword');
|
||||
|
||||
// Ne doit pas lever d'exception
|
||||
$this->service->changePassword(1, $password, '123456789012');
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit lever WeakPasswordException si le nouveau mot de passe est trop court.
|
||||
*/
|
||||
public function testChangePasswordTooShortNewPasswordThrowsWeakPasswordException(): void
|
||||
{
|
||||
$password = 'ancienmdp12';
|
||||
$user = $this->makeUser('alice', 'alice@example.com', $password, 1);
|
||||
$this->userRepository->method('findById')->willReturn($user);
|
||||
|
||||
$this->expectException(WeakPasswordException::class);
|
||||
|
||||
$this->service->changePassword(1, $password, '12345678901');
|
||||
}
|
||||
|
||||
/**
|
||||
* changePassword() doit lever NotFoundException si l'utilisateur est introuvable.
|
||||
*/
|
||||
public function testChangePasswordUnknownUserThrowsNotFoundException(): void
|
||||
{
|
||||
$this->userRepository->method('findById')->willReturn(null);
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
|
||||
$this->service->changePassword(99, 'ancienmdp12', 'nouveaumdp12');
|
||||
}
|
||||
|
||||
|
||||
// ── login / logout / isLoggedIn ────────────────────────────────
|
||||
|
||||
/**
|
||||
* login() doit appeler SessionManager::setUser() avec les bonnes données.
|
||||
*/
|
||||
public function testLoginCallsSetUser(): void
|
||||
{
|
||||
$user = $this->makeUser('alice', 'alice@example.com', 'secret', 7, User::ROLE_ADMIN);
|
||||
|
||||
$this->authSession->expects($this->once())
|
||||
->method('startForUser')
|
||||
->with($user);
|
||||
|
||||
$this->service->login($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* logout() doit appeler SessionManager::destroy().
|
||||
*/
|
||||
public function testLogoutCallsDestroy(): void
|
||||
{
|
||||
$this->authSession->expects($this->once())->method('clear');
|
||||
|
||||
$this->service->logout();
|
||||
}
|
||||
|
||||
/**
|
||||
* isLoggedIn() doit déléguer à SessionManager::isAuthenticated().
|
||||
*/
|
||||
public function testIsLoggedInDelegatesToSessionManager(): void
|
||||
{
|
||||
$this->authSession->method('isAuthenticated')->willReturn(true);
|
||||
|
||||
$this->assertTrue($this->service->isLoggedIn());
|
||||
}
|
||||
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni.
|
||||
*
|
||||
* @param string $username Nom d'utilisateur
|
||||
* @param string $email Adresse e-mail
|
||||
* @param string $password Mot de passe en clair (haché en bcrypt)
|
||||
* @param int $id Identifiant (défaut : 1)
|
||||
* @param string $role Rôle (défaut : 'user')
|
||||
*/
|
||||
private function makeUser(
|
||||
string $username,
|
||||
string $email,
|
||||
string $password = 'motdepasse12',
|
||||
int $id = 1,
|
||||
string $role = User::ROLE_USER,
|
||||
): User {
|
||||
return new User(
|
||||
$id,
|
||||
$username,
|
||||
$email,
|
||||
password_hash($password, PASSWORD_BCRYPT),
|
||||
$role,
|
||||
);
|
||||
}
|
||||
}
|
||||
36
tests/Identity/AuthorizationServiceTest.php
Normal file
36
tests/Identity/AuthorizationServiceTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\AuthorizationApplicationService;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Policy\Permission;
|
||||
use Netig\Netslim\Identity\Domain\Policy\RolePermissionMatrix;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AuthorizationServiceTest extends TestCase
|
||||
{
|
||||
private AuthorizationApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->service = new AuthorizationApplicationService(new RolePermissionMatrix());
|
||||
}
|
||||
|
||||
public function testEditorGetsFineGrainedPermissions(): void
|
||||
{
|
||||
$editor = new User(1, 'editor', 'editor@example.test', 'hash', User::ROLE_EDITOR);
|
||||
|
||||
self::assertTrue($this->service->canUser($editor, Permission::CONTENT_PUBLISH));
|
||||
self::assertTrue($this->service->canUser($editor, Permission::MEDIA_MANAGE));
|
||||
self::assertFalse($this->service->canUser($editor, Permission::USERS_MANAGE));
|
||||
}
|
||||
|
||||
public function testAdminHasWildcardPermissions(): void
|
||||
{
|
||||
self::assertTrue($this->service->canRole(User::ROLE_ADMIN, Permission::SETTINGS_MANAGE));
|
||||
self::assertContains('*', $this->service->permissionsForRole(User::ROLE_ADMIN));
|
||||
}
|
||||
}
|
||||
286
tests/Identity/LoginAttemptRepositoryTest.php
Normal file
286
tests/Identity/LoginAttemptRepositoryTest.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Infrastructure\PdoLoginAttemptRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PdoLoginAttemptRepository.
|
||||
*
|
||||
* Vérifie la logique de gestion des tentatives de connexion :
|
||||
* lecture par IP, enregistrement d'un échec, réinitialisation ciblée
|
||||
* et nettoyage des entrées expirées.
|
||||
*
|
||||
* Les assertions privilégient l'intention métier (opération, table,
|
||||
* paramètres liés, horodatage cohérent) plutôt que la forme SQL exacte,
|
||||
* afin de laisser un peu plus de liberté d'évolution interne.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class LoginAttemptRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PdoLoginAttemptRepository $repository;
|
||||
|
||||
/**
|
||||
* Initialise le mock PDO et le dépôt avant chaque test.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->repository = new PdoLoginAttemptRepository($this->db);
|
||||
}
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────
|
||||
|
||||
private function stmtOk(): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn(false);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── findByIp ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findByIp() doit retourner null si aucune entrée n'existe pour cette IP.
|
||||
*/
|
||||
public function testFindByIpReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn(false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findByIp('192.168.1.1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByIp() doit retourner le tableau associatif si une entrée existe.
|
||||
*/
|
||||
public function testFindByIpReturnsRowWhenFound(): void
|
||||
{
|
||||
$row = [
|
||||
'scope' => 'login_ip',
|
||||
'rate_key' => '192.168.1.1',
|
||||
'attempts' => 3,
|
||||
'locked_until' => null,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn($row);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame($row, $this->repository->findByIp('192.168.1.1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByIp() exécute avec la bonne IP.
|
||||
*/
|
||||
public function testFindByIpQueriesWithCorrectIp(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':scope' => 'login_ip', ':rate_key' => '10.0.0.1']);
|
||||
|
||||
$this->repository->findByIp('10.0.0.1');
|
||||
}
|
||||
|
||||
|
||||
// ── recordFailure — UPSERT atomique ────────────────────────────
|
||||
|
||||
/**
|
||||
* recordFailure() doit préparer une écriture sur rate_limits
|
||||
* puis exécuter l'opération avec les bons paramètres métier.
|
||||
*/
|
||||
public function testRecordFailureUsesUpsertSql(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('rate_limits'),
|
||||
$this->stringContains('attempts'),
|
||||
$this->stringContains('locked_until'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit passer la bonne IP au paramètre :ip.
|
||||
*/
|
||||
public function testRecordFailurePassesCorrectIp(): void
|
||||
{
|
||||
$ip = '10.0.0.42';
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $p): bool => $p[':scope1'] === 'login_ip' && $p[':rate_key1'] === $ip));
|
||||
|
||||
$this->repository->recordFailure($ip, 5, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit passer le seuil maxAttempts sous les deux alias :max1 et :max2.
|
||||
*
|
||||
* PDO interdit les paramètres nommés dupliqués dans une même requête ;
|
||||
* le seuil est donc passé deux fois avec des noms distincts.
|
||||
*/
|
||||
public function testRecordFailurePassesMaxAttemptsUnderBothAliases(): void
|
||||
{
|
||||
$max = 7;
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(
|
||||
fn (array $p): bool =>
|
||||
$p[':max1'] === $max && $p[':max2'] === $max,
|
||||
));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', $max, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit calculer locked_until dans la fenêtre attendue.
|
||||
*
|
||||
* :lock1 et :lock2 doivent être égaux et correspondre à
|
||||
* maintenant + lockMinutes (± 5 secondes de tolérance d'exécution).
|
||||
*/
|
||||
public function testRecordFailureLockedUntilIsInCorrectWindow(): void
|
||||
{
|
||||
$lockMinutes = 15;
|
||||
$before = time() + $lockMinutes * 60 - 5;
|
||||
$after = time() + $lockMinutes * 60 + 5;
|
||||
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $p) use ($before, $after): bool {
|
||||
foreach ([':lock1', ':lock2'] as $key) {
|
||||
if (!isset($p[$key])) {
|
||||
return false;
|
||||
}
|
||||
$ts = strtotime($p[$key]);
|
||||
if ($ts < $before || $ts > $after) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $p[':lock1'] === $p[':lock2'];
|
||||
}));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, $lockMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* recordFailure() doit horodater :now1 et :now2 dans la fenêtre de l'instant présent.
|
||||
*/
|
||||
public function testRecordFailureNowParamsAreCurrentTime(): void
|
||||
{
|
||||
$before = time() - 2;
|
||||
$after = time() + 2;
|
||||
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $p) use ($before, $after): bool {
|
||||
foreach ([':now1', ':now2'] as $key) {
|
||||
if (!isset($p[$key])) {
|
||||
return false;
|
||||
}
|
||||
$ts = strtotime($p[$key]);
|
||||
if ($ts < $before || $ts > $after) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $p[':now1'] === $p[':now2'];
|
||||
}));
|
||||
|
||||
$this->repository->recordFailure('192.168.1.1', 5, 15);
|
||||
}
|
||||
|
||||
|
||||
// ── resetForIp ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* resetForIp() doit préparer une suppression ciblant la bonne IP.
|
||||
*/
|
||||
public function testResetForIpCallsDeleteWithCorrectIp(): void
|
||||
{
|
||||
$ip = '10.0.0.42';
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('rate_limits'),
|
||||
$this->stringContains('DELETE'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':scope' => 'login_ip', ':rate_key' => $ip]);
|
||||
|
||||
$this->repository->resetForIp($ip);
|
||||
}
|
||||
|
||||
|
||||
// ── deleteExpired ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* deleteExpired() doit préparer une suppression sur rate_limits
|
||||
* et lier une date au format 'Y-m-d H:i:s' comme paramètre :now.
|
||||
*/
|
||||
public function testDeleteExpiredExecutesQueryWithCurrentTimestamp(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('rate_limits'),
|
||||
$this->stringContains('DELETE'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $params): bool {
|
||||
return isset($params[':now'])
|
||||
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $params[':now']);
|
||||
}));
|
||||
|
||||
$this->repository->deleteExpired();
|
||||
}
|
||||
}
|
||||
110
tests/Identity/MiddlewareTest.php
Normal file
110
tests/Identity/MiddlewareTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\UI\Http\Middleware\AdminMiddleware;
|
||||
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
|
||||
use Netig\Netslim\Identity\UI\Http\Middleware\EditorMiddleware;
|
||||
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
use Slim\Psr7\Response;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class MiddlewareTest extends TestCase
|
||||
{
|
||||
/** @var SessionManagerInterface&MockObject */
|
||||
private SessionManagerInterface $sessionManager;
|
||||
|
||||
private ServerRequestInterface $request;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
$this->request = (new ServerRequestFactory())->createServerRequest('GET', '/admin');
|
||||
}
|
||||
|
||||
public function testAuthMiddlewareRedirectsGuests(): void
|
||||
{
|
||||
$this->sessionManager->method('isAuthenticated')->willReturn(false);
|
||||
|
||||
$middleware = new AuthMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler());
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertSame('/auth/login', $response->getHeaderLine('Location'));
|
||||
}
|
||||
|
||||
public function testAuthMiddlewareDelegatesWhenAuthenticated(): void
|
||||
{
|
||||
$this->sessionManager->method('isAuthenticated')->willReturn(true);
|
||||
|
||||
$middleware = new AuthMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler(204));
|
||||
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAdminMiddlewareRedirectsNonAdmins(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
|
||||
$middleware = new AdminMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler());
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertSame('/admin', $response->getHeaderLine('Location'));
|
||||
}
|
||||
|
||||
public function testAdminMiddlewareDelegatesForAdmins(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
|
||||
$middleware = new AdminMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler(204));
|
||||
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testEditorMiddlewareRedirectsWhenNeitherAdminNorEditor(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
|
||||
$middleware = new EditorMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler());
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertSame('/admin', $response->getHeaderLine('Location'));
|
||||
}
|
||||
|
||||
public function testEditorMiddlewareDelegatesForEditors(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(true);
|
||||
|
||||
$middleware = new EditorMiddleware($this->sessionManager);
|
||||
$response = $middleware->process($this->request, $this->makeHandler(204));
|
||||
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
}
|
||||
|
||||
private function makeHandler(int $status = 200): RequestHandlerInterface
|
||||
{
|
||||
return new class ($status) implements RequestHandlerInterface {
|
||||
public function __construct(private readonly int $status) {}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
return new Response($this->status);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
85
tests/Identity/PasswordRequestHandlingTest.php
Normal file
85
tests/Identity/PasswordRequestHandlingTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\UI\Http\Request\ChangePasswordRequest;
|
||||
use Netig\Netslim\Identity\UI\Http\Request\CreateUserRequest;
|
||||
use Netig\Netslim\Identity\UI\Http\Request\LoginRequest;
|
||||
use Netig\Netslim\Identity\UI\Http\Request\ResetPasswordRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
|
||||
final class PasswordRequestHandlingTest extends TestCase
|
||||
{
|
||||
public function testLoginRequestPreservesPasswordWhitespace(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())
|
||||
->createServerRequest('POST', '/login')
|
||||
->withParsedBody([
|
||||
'username' => ' alice ',
|
||||
'password' => ' secret ',
|
||||
]);
|
||||
|
||||
$payload = LoginRequest::fromRequest($request);
|
||||
|
||||
self::assertSame('alice', $payload->username);
|
||||
self::assertSame(' secret ', $payload->password);
|
||||
}
|
||||
|
||||
public function testChangePasswordRequestPreservesPasswordWhitespace(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())
|
||||
->createServerRequest('POST', '/account/password')
|
||||
->withParsedBody([
|
||||
'current_password' => ' current ',
|
||||
'new_password' => ' nextpass ',
|
||||
'new_password_confirm' => ' nextpass ',
|
||||
]);
|
||||
|
||||
$payload = ChangePasswordRequest::fromRequest($request);
|
||||
|
||||
self::assertSame(' current ', $payload->currentPassword);
|
||||
self::assertSame(' nextpass ', $payload->newPassword);
|
||||
self::assertSame(' nextpass ', $payload->newPasswordConfirm);
|
||||
}
|
||||
|
||||
public function testResetPasswordRequestPreservesPasswordWhitespace(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())
|
||||
->createServerRequest('POST', '/password/reset')
|
||||
->withParsedBody([
|
||||
'token' => ' token ',
|
||||
'new_password' => ' nextpass ',
|
||||
'new_password_confirm' => ' nextpass ',
|
||||
]);
|
||||
|
||||
$payload = ResetPasswordRequest::fromRequest($request);
|
||||
|
||||
self::assertSame('token', $payload->token);
|
||||
self::assertSame(' nextpass ', $payload->newPassword);
|
||||
self::assertSame(' nextpass ', $payload->newPasswordConfirm);
|
||||
}
|
||||
|
||||
public function testCreateUserRequestPreservesPasswordWhitespace(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())
|
||||
->createServerRequest('POST', '/admin/users')
|
||||
->withParsedBody([
|
||||
'username' => ' Alice ',
|
||||
'email' => ' alice@example.com ',
|
||||
'password' => ' secret123 ',
|
||||
'password_confirm' => ' secret123 ',
|
||||
'role' => 'editor',
|
||||
]);
|
||||
|
||||
$payload = CreateUserRequest::fromRequest($request, ['user', 'editor']);
|
||||
|
||||
self::assertSame('Alice', $payload->username);
|
||||
self::assertSame('alice@example.com', $payload->email);
|
||||
self::assertSame(' secret123 ', $payload->password);
|
||||
self::assertSame(' secret123 ', $payload->passwordConfirm);
|
||||
self::assertSame('editor', $payload->role);
|
||||
}
|
||||
}
|
||||
435
tests/Identity/PasswordResetControllerTest.php
Normal file
435
tests/Identity/PasswordResetControllerTest.php
Normal file
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\AuthServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\PasswordResetServiceInterface;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
|
||||
use Netig\Netslim\Identity\UI\Http\PasswordResetController;
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PasswordResetController.
|
||||
*
|
||||
* Couvre le flux en deux étapes :
|
||||
* 1. showForgot() / forgot() — demande de réinitialisation
|
||||
* 2. showReset() / reset() — saisie du nouveau mot de passe
|
||||
*
|
||||
* Points clés :
|
||||
* - forgot() vérifie le rate limit par IP avant tout traitement
|
||||
* - forgot() enregistre systématiquement une tentative (anti-canal-caché)
|
||||
* - forgot() affiche toujours un message de succès générique (anti-énumération)
|
||||
* - showReset() valide le token avant d'afficher le formulaire
|
||||
* - reset() couvre 5 chemins de sortie (token vide, mismatch, trop court, invalide, succès)
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PasswordResetControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
/** @var PasswordResetServiceInterface&MockObject */
|
||||
private PasswordResetServiceInterface $passwordResetService;
|
||||
|
||||
/** @var AuthServiceInterface&MockObject */
|
||||
private AuthServiceInterface $authService;
|
||||
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
private ClientIpResolver $clientIpResolver;
|
||||
|
||||
private PasswordResetController $controller;
|
||||
|
||||
private const BASE_URL = 'https://example.com';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->passwordResetService = $this->createMock(PasswordResetServiceInterface::class);
|
||||
$this->authService = $this->createMock(AuthServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
$this->clientIpResolver = new ClientIpResolver(['*']);
|
||||
|
||||
// Par défaut : IP non verrouillée
|
||||
$this->authService->method('checkPasswordResetRateLimit')->willReturn(0);
|
||||
|
||||
$this->controller = new PasswordResetController(
|
||||
$this->view,
|
||||
$this->passwordResetService,
|
||||
$this->authService,
|
||||
$this->flash,
|
||||
$this->clientIpResolver,
|
||||
self::BASE_URL,
|
||||
);
|
||||
}
|
||||
|
||||
// ── showForgot ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* showForgot() doit rendre le formulaire de demande de réinitialisation.
|
||||
*/
|
||||
public function testShowForgotRendersForm(): void
|
||||
{
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Identity/password-forgot.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->showForgot($this->makeGet('/password/forgot'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── forgot — rate limiting ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* forgot() doit rediriger avec une erreur générique si l'IP est verrouillée.
|
||||
*
|
||||
* Le message ne mentionne pas l'email pour ne pas révéler qu'une demande
|
||||
* pour cette adresse a déjà été traitée.
|
||||
*
|
||||
* Note : le mock setUp() est reconfiguré ici via un nouveau mock pour éviter
|
||||
* le conflit avec le willReturn(0) global — en PHPUnit 11 la première
|
||||
* configuration prend la main sur les suivantes pour le même matcher any().
|
||||
*/
|
||||
public function testForgotRedirectsWhenRateLimited(): void
|
||||
{
|
||||
$authService = $this->createMock(AuthServiceInterface::class);
|
||||
$authService->expects($this->once())
|
||||
->method('checkPasswordResetRateLimit')
|
||||
->with('203.0.113.5')
|
||||
->willReturn(10);
|
||||
|
||||
$controller = new PasswordResetController(
|
||||
$this->view,
|
||||
$this->passwordResetService,
|
||||
$authService,
|
||||
$this->flash,
|
||||
$this->clientIpResolver,
|
||||
self::BASE_URL,
|
||||
);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('Trop de demandes'));
|
||||
|
||||
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
|
||||
'REMOTE_ADDR' => '127.0.0.1',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
|
||||
]);
|
||||
$res = $controller->forgot($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/forgot');
|
||||
}
|
||||
|
||||
/**
|
||||
* forgot() ne doit pas appeler requestReset() si l'IP est verrouillée.
|
||||
*/
|
||||
public function testForgotDoesNotCallServiceWhenRateLimited(): void
|
||||
{
|
||||
$authService = $this->createMock(AuthServiceInterface::class);
|
||||
$authService->expects($this->once())
|
||||
->method('checkPasswordResetRateLimit')
|
||||
->with('203.0.113.5')
|
||||
->willReturn(5);
|
||||
|
||||
$controller = new PasswordResetController(
|
||||
$this->view,
|
||||
$this->passwordResetService,
|
||||
$authService,
|
||||
$this->flash,
|
||||
$this->clientIpResolver,
|
||||
self::BASE_URL,
|
||||
);
|
||||
|
||||
$this->passwordResetService->expects($this->never())->method('requestReset');
|
||||
|
||||
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
|
||||
'REMOTE_ADDR' => '127.0.0.1',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
|
||||
]);
|
||||
$controller->forgot($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* forgot() doit enregistrer une tentative pour chaque demande, quel que soit le résultat.
|
||||
*
|
||||
* Réinitialiser le compteur uniquement en cas de succès constituerait un canal
|
||||
* caché permettant de déduire si l'adresse email est enregistrée.
|
||||
*/
|
||||
public function testForgotAlwaysRecordsFailure(): void
|
||||
{
|
||||
$this->authService->expects($this->once())
|
||||
->method('recordPasswordResetAttempt')
|
||||
->with('203.0.113.5');
|
||||
|
||||
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com'], [
|
||||
'REMOTE_ADDR' => '127.0.0.1',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.5, 198.51.100.12',
|
||||
]);
|
||||
$this->controller->forgot($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* forgot() ne doit jamais réinitialiser le compteur de rate limit.
|
||||
*
|
||||
* Un reset sur succès révélerait l'existence du compte (canal caché).
|
||||
*/
|
||||
public function testForgotNeverResetsRateLimit(): void
|
||||
{
|
||||
$this->authService->expects($this->never())->method('resetRateLimit');
|
||||
|
||||
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
|
||||
$this->controller->forgot($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
// ── forgot — comportement nominal ────────────────────────────────
|
||||
|
||||
/**
|
||||
* forgot() doit afficher un message de succès générique même si l'email est inconnu.
|
||||
*
|
||||
* Protection contre l'énumération des comptes : le comportement externe
|
||||
* ne doit pas révéler si l'adresse est enregistrée.
|
||||
*/
|
||||
public function testForgotAlwaysShowsGenericSuccessMessage(): void
|
||||
{
|
||||
// requestReset() est void — aucun stub nécessaire, le mock ne lève pas d'exception par défaut
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_success', $this->stringContains('Si cette adresse'));
|
||||
|
||||
$req = $this->makePost('/password/forgot', ['email' => 'unknown@example.com']);
|
||||
$res = $this->controller->forgot($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/forgot');
|
||||
}
|
||||
|
||||
/**
|
||||
* forgot() doit afficher une erreur et rediriger si l'envoi d'email échoue.
|
||||
*/
|
||||
public function testForgotRedirectsWithErrorOnMailFailure(): void
|
||||
{
|
||||
$this->passwordResetService->method('requestReset')
|
||||
->willThrowException(new \RuntimeException('SMTP error'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('erreur est survenue'));
|
||||
|
||||
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
|
||||
$res = $this->controller->forgot($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/forgot');
|
||||
}
|
||||
|
||||
/**
|
||||
* forgot() doit transmettre l'APP_URL au service lors de la demande.
|
||||
*/
|
||||
public function testForgotPassesBaseUrlToService(): void
|
||||
{
|
||||
$this->passwordResetService->expects($this->once())
|
||||
->method('requestReset')
|
||||
->with($this->anything(), self::BASE_URL);
|
||||
|
||||
$req = $this->makePost('/password/forgot', ['email' => 'alice@example.com']);
|
||||
$this->controller->forgot($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
// ── showReset ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* showReset() doit rediriger avec une erreur si le paramètre token est absent.
|
||||
*/
|
||||
public function testShowResetRedirectsWhenTokenMissing(): void
|
||||
{
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('manquant'));
|
||||
|
||||
$res = $this->controller->showReset($this->makeGet('/password/reset'), $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/forgot');
|
||||
}
|
||||
|
||||
/**
|
||||
* showReset() doit rediriger avec une erreur si le token est invalide ou expiré.
|
||||
*/
|
||||
public function testShowResetRedirectsWhenTokenInvalid(): void
|
||||
{
|
||||
$this->passwordResetService->method('validateToken')->willReturn(null);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('invalide'));
|
||||
|
||||
$req = $this->makeGet('/password/reset', ['token' => 'invalid-token']);
|
||||
$res = $this->controller->showReset($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/forgot');
|
||||
}
|
||||
|
||||
/**
|
||||
* showReset() doit rendre le formulaire si le token est valide.
|
||||
*/
|
||||
public function testShowResetRendersFormWhenTokenValid(): void
|
||||
{
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
$this->passwordResetService->method('validateToken')->willReturn($user);
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Identity/password-reset.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$req = $this->makeGet('/password/reset', ['token' => 'valid-token']);
|
||||
$res = $this->controller->showReset($req, $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── reset ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* reset() doit rediriger vers /password/forgot si le token est absent du corps.
|
||||
*/
|
||||
public function testResetRedirectsToForgotWhenTokenMissing(): void
|
||||
{
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('manquant'));
|
||||
|
||||
$req = $this->makePost('/password/reset', [
|
||||
'token' => '',
|
||||
'new_password' => 'newpass123',
|
||||
'new_password_confirm' => 'newpass123',
|
||||
]);
|
||||
$res = $this->controller->reset($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/forgot');
|
||||
}
|
||||
|
||||
/**
|
||||
* reset() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
|
||||
*/
|
||||
public function testResetRedirectsWhenPasswordMismatch(): void
|
||||
{
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('correspondent pas'));
|
||||
|
||||
$req = $this->makePost('/password/reset', [
|
||||
'token' => 'abc123',
|
||||
'new_password' => 'newpass1',
|
||||
'new_password_confirm' => 'newpass2',
|
||||
]);
|
||||
$res = $this->controller->reset($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/reset?token=abc123');
|
||||
}
|
||||
|
||||
/**
|
||||
* reset() doit rediriger avec une erreur si le nouveau mot de passe est trop court.
|
||||
*/
|
||||
public function testResetRedirectsOnWeakPassword(): void
|
||||
{
|
||||
$this->passwordResetService->method('resetPassword')
|
||||
->willThrowException(new WeakPasswordException());
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('12 caractères'));
|
||||
|
||||
$req = $this->makePost('/password/reset', [
|
||||
'token' => 'abc123',
|
||||
'new_password' => 'short',
|
||||
'new_password_confirm' => 'short',
|
||||
]);
|
||||
$res = $this->controller->reset($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/reset?token=abc123');
|
||||
}
|
||||
|
||||
/**
|
||||
* reset() doit rediriger avec une erreur si le token est invalide ou expiré.
|
||||
*/
|
||||
public function testResetRedirectsOnInvalidToken(): void
|
||||
{
|
||||
$this->passwordResetService->method('resetPassword')
|
||||
->willThrowException(new InvalidResetTokenException('Token invalide'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('invalide'));
|
||||
|
||||
$req = $this->makePost('/password/reset', [
|
||||
'token' => 'expired-token',
|
||||
'new_password' => 'newpass123',
|
||||
'new_password_confirm' => 'newpass123',
|
||||
]);
|
||||
$res = $this->controller->reset($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/reset?token=expired-token');
|
||||
}
|
||||
|
||||
/**
|
||||
* reset() doit rediriger avec une erreur générique en cas d'exception inattendue.
|
||||
*/
|
||||
public function testResetRedirectsOnUnexpectedError(): void
|
||||
{
|
||||
$this->passwordResetService->method('resetPassword')
|
||||
->willThrowException(new \RuntimeException('DB error'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('reset_error', $this->stringContains('inattendue'));
|
||||
|
||||
$req = $this->makePost('/password/reset', [
|
||||
'token' => 'abc123',
|
||||
'new_password' => 'newpass123',
|
||||
'new_password_confirm' => 'newpass123',
|
||||
]);
|
||||
$res = $this->controller->reset($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/password/reset?token=abc123');
|
||||
}
|
||||
|
||||
/**
|
||||
* reset() doit flasher un succès et rediriger vers /auth/login en cas de succès.
|
||||
*/
|
||||
public function testResetRedirectsToLoginOnSuccess(): void
|
||||
{
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('login_success', $this->stringContains('réinitialisé'));
|
||||
|
||||
$req = $this->makePost('/password/reset', [
|
||||
'token' => 'valid-token',
|
||||
'new_password' => 'newpass123',
|
||||
'new_password_confirm' => 'newpass123',
|
||||
]);
|
||||
$res = $this->controller->reset($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/auth/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* reset() doit encoder correctement le token dans l'URL de redirection en cas d'erreur.
|
||||
*/
|
||||
public function testResetEncodesTokenInRedirectUrl(): void
|
||||
{
|
||||
$this->passwordResetService->method('resetPassword')
|
||||
->willThrowException(new WeakPasswordException());
|
||||
$this->flash->method('set');
|
||||
|
||||
$token = 'tok/en+with=special&chars';
|
||||
$req = $this->makePost('/password/reset', [
|
||||
'token' => $token,
|
||||
'new_password' => 'x',
|
||||
'new_password_confirm' => 'x',
|
||||
]);
|
||||
$res = $this->controller->reset($req, $this->makeResponse());
|
||||
|
||||
$this->assertSame(
|
||||
'/password/reset?token=' . urlencode($token),
|
||||
$res->getHeaderLine('Location'),
|
||||
);
|
||||
}
|
||||
}
|
||||
264
tests/Identity/PasswordResetRepositoryTest.php
Normal file
264
tests/Identity/PasswordResetRepositoryTest.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Infrastructure\PdoPasswordResetRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PdoPasswordResetRepository.
|
||||
*
|
||||
* Vérifie les opérations de persistance des tokens de réinitialisation.
|
||||
*
|
||||
* Les assertions privilégient l'intention (lecture, création, invalidation,
|
||||
* consommation atomique) et les paramètres métier importants plutôt que
|
||||
* la forme exacte du SQL.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PasswordResetRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PdoPasswordResetRepository $repository;
|
||||
|
||||
/**
|
||||
* Initialise le mock PDO et le dépôt avant chaque test.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->repository = new PdoPasswordResetRepository($this->db);
|
||||
}
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────
|
||||
|
||||
private function stmtOk(array|false $fetchReturn = false): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetch')->willReturn($fetchReturn);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── create ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() doit préparer un INSERT sur 'password_resets'
|
||||
* avec le user_id, le token_hash et la date d'expiration fournis.
|
||||
*/
|
||||
public function testCreateCallsInsertWithCorrectData(): void
|
||||
{
|
||||
$userId = 1;
|
||||
$tokenHash = hash('sha256', 'montokenbrut');
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + 3600);
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())->method('prepare')
|
||||
->with($this->stringContains('INSERT INTO password_resets'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data) use ($userId, $tokenHash, $expiresAt): bool {
|
||||
return $data[':user_id'] === $userId
|
||||
&& $data[':token_hash'] === $tokenHash
|
||||
&& $data[':expires_at'] === $expiresAt
|
||||
&& isset($data[':created_at']);
|
||||
}));
|
||||
|
||||
$this->repository->create($userId, $tokenHash, $expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit renseigner :created_at au format 'Y-m-d H:i:s'.
|
||||
*/
|
||||
public function testCreateSetsCreatedAt(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data): bool {
|
||||
return isset($data[':created_at'])
|
||||
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':created_at']);
|
||||
}));
|
||||
|
||||
$this->repository->create(1, 'hash', date('Y-m-d H:i:s', time() + 3600));
|
||||
}
|
||||
|
||||
|
||||
// ── findActiveByHash ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findActiveByHash() doit retourner null si aucun token actif ne correspond au hash.
|
||||
*/
|
||||
public function testFindActiveByHashReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtOk(false);
|
||||
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findActiveByHash('hashquinaexistepas'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findActiveByHash() doit retourner la ligne si un token actif correspond au hash.
|
||||
*/
|
||||
public function testFindActiveByHashReturnsRowWhenFound(): void
|
||||
{
|
||||
$tokenHash = hash('sha256', 'montokenbrut');
|
||||
$row = [
|
||||
'id' => 1,
|
||||
'user_id' => 42,
|
||||
'token_hash' => $tokenHash,
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
|
||||
'used_at' => null,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
$stmt = $this->stmtOk($row);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame($row, $this->repository->findActiveByHash($tokenHash));
|
||||
}
|
||||
|
||||
/**
|
||||
* findActiveByHash() doit préparer une lecture sur password_resets
|
||||
* puis lier le hash demandé.
|
||||
*/
|
||||
public function testFindActiveByHashFiltersOnNullUsedAt(): void
|
||||
{
|
||||
$tokenHash = hash('sha256', 'montokenbrut');
|
||||
$stmt = $this->stmtOk(false);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('password_resets'),
|
||||
$this->stringContains('token_hash'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':token_hash' => $tokenHash]);
|
||||
|
||||
$this->repository->findActiveByHash($tokenHash);
|
||||
}
|
||||
|
||||
|
||||
// ── invalidateByUserId ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* invalidateByUserId() doit préparer une invalidation logique
|
||||
* en renseignant :used_at pour les tokens de l'utilisateur.
|
||||
*/
|
||||
public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void
|
||||
{
|
||||
$userId = 42;
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('password_resets'),
|
||||
$this->stringContains('UPDATE'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data) use ($userId): bool {
|
||||
return $data[':user_id'] === $userId
|
||||
&& isset($data[':used_at'])
|
||||
&& (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':used_at']);
|
||||
}));
|
||||
|
||||
$this->repository->invalidateByUserId($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* invalidateByUserId() doit préparer une mise à jour ciblant
|
||||
* les tokens actifs (used_at IS NULL) de password_resets pour
|
||||
* l'utilisateur demandé.
|
||||
*/
|
||||
public function testInvalidateByUserIdFiltersOnActiveTokens(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('password_resets'),
|
||||
$this->stringContains('user_id'),
|
||||
$this->stringContains('used_at IS NULL'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->repository->invalidateByUserId(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* invalidateByUserId() ne doit pas utiliser DELETE — les tokens
|
||||
* sont invalidés via used_at pour conserver la traçabilité.
|
||||
*/
|
||||
public function testInvalidateByUserIdNeverCallsDelete(): void
|
||||
{
|
||||
$stmt = $this->stmtOk();
|
||||
$this->db->expects($this->once())->method('prepare')
|
||||
->with($this->stringContains('UPDATE'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->db->expects($this->never())->method('exec');
|
||||
|
||||
$this->repository->invalidateByUserId(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── consumeActiveToken ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* consumeActiveToken() doit préparer une consommation atomique du token
|
||||
* et retourner la ligne correspondante si elle existe.
|
||||
*/
|
||||
public function testConsumeActiveTokenUsesAtomicUpdateReturning(): void
|
||||
{
|
||||
$row = ['id' => 1, 'user_id' => 42, 'token_hash' => 'hash', 'used_at' => null];
|
||||
$stmt = $this->stmtOk($row);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->logicalAnd(
|
||||
$this->stringContains('password_resets'),
|
||||
$this->stringContains('UPDATE'),
|
||||
$this->stringContains('RETURNING'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->consumeActiveToken('hash', '2024-01-01 00:00:00');
|
||||
|
||||
$this->assertSame($row, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* consumeActiveToken() retourne null si aucun token actif ne correspond.
|
||||
*/
|
||||
public function testConsumeActiveTokenReturnsNullWhenNoRowMatches(): void
|
||||
{
|
||||
$stmt = $this->stmtOk(false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->consumeActiveToken('missing', '2024-01-01 00:00:00'));
|
||||
}
|
||||
}
|
||||
91
tests/Identity/PasswordResetServiceIntegrationTest.php
Normal file
91
tests/Identity/PasswordResetServiceIntegrationTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\PasswordResetApplicationService;
|
||||
use Netig\Netslim\Identity\Application\UseCase\RequestPasswordReset;
|
||||
use Netig\Netslim\Identity\Application\UseCase\ResetPassword;
|
||||
use Netig\Netslim\Identity\Application\UseCase\ValidatePasswordResetToken;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
|
||||
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Policy\PasswordResetTokenPolicy;
|
||||
use Netig\Netslim\Identity\Infrastructure\PdoLoginAttemptRepository;
|
||||
use Netig\Netslim\Identity\Infrastructure\PdoPasswordResetRepository;
|
||||
use Netig\Netslim\Identity\Infrastructure\PdoUserRepository;
|
||||
use Netig\Netslim\Kernel\Mail\Application\MailServiceInterface;
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\Migrator;
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\PdoTransactionManager;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class PasswordResetServiceIntegrationTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
private PasswordResetApplicationService $service;
|
||||
|
||||
private PdoUserRepository $users;
|
||||
|
||||
private PdoPasswordResetRepository $resets;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
|
||||
Migrator::run($this->db);
|
||||
|
||||
$this->users = new PdoUserRepository($this->db);
|
||||
$this->resets = new PdoPasswordResetRepository($this->db);
|
||||
$mail = new class () implements MailServiceInterface {
|
||||
public function send(string $to, string $subject, string $template, array $context = []): void {}
|
||||
};
|
||||
|
||||
$this->service = new PasswordResetApplicationService(
|
||||
new RequestPasswordReset(
|
||||
$this->resets,
|
||||
$this->users,
|
||||
$mail,
|
||||
new PasswordResetTokenPolicy(),
|
||||
new PdoLoginAttemptRepository($this->db),
|
||||
new LoginRateLimitPolicy(),
|
||||
),
|
||||
new ValidatePasswordResetToken($this->resets, $this->users),
|
||||
new ResetPassword(
|
||||
$this->resets,
|
||||
$this->users,
|
||||
new PdoTransactionManager($this->db),
|
||||
new PasswordPolicy(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function testResetPasswordConsumesTokenOnlyOnceAndUpdatesPassword(): void
|
||||
{
|
||||
$userId = $this->users->create(new User(0, 'alice', 'alice@example.com', password_hash('ancienpass12', PASSWORD_BCRYPT)));
|
||||
$tokenRaw = 'token-brut-integration';
|
||||
$tokenHash = hash('sha256', $tokenRaw);
|
||||
$this->resets->create($userId, $tokenHash, date('Y-m-d H:i:s', time() + 3600));
|
||||
|
||||
$this->service->resetPassword($tokenRaw, 'nouveaupass12');
|
||||
|
||||
$user = $this->users->findById($userId);
|
||||
self::assertNotNull($user);
|
||||
self::assertTrue(password_verify('nouveaupass12', $user->getPasswordHash()));
|
||||
|
||||
$row = $this->db->query("SELECT used_at FROM password_resets WHERE token_hash = '{$tokenHash}'")->fetch();
|
||||
self::assertIsArray($row);
|
||||
self::assertNotEmpty($row['used_at']);
|
||||
|
||||
$this->expectException(InvalidResetTokenException::class);
|
||||
$this->service->resetPassword($tokenRaw, 'encoreplusfort1');
|
||||
}
|
||||
}
|
||||
341
tests/Identity/PasswordResetServiceTest.php
Normal file
341
tests/Identity/PasswordResetServiceTest.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\PasswordResetApplicationService;
|
||||
use Netig\Netslim\Identity\Application\UseCase\RequestPasswordReset;
|
||||
use Netig\Netslim\Identity\Application\UseCase\ResetPassword;
|
||||
use Netig\Netslim\Identity\Application\UseCase\ValidatePasswordResetToken;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Exception\InvalidResetTokenException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
|
||||
use Netig\Netslim\Identity\Domain\Policy\LoginRateLimitPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Policy\PasswordResetTokenPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Repository\LoginAttemptRepositoryInterface;
|
||||
use Netig\Netslim\Identity\Domain\Repository\PasswordResetRepositoryInterface;
|
||||
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
|
||||
use Netig\Netslim\Kernel\Mail\Application\MailServiceInterface;
|
||||
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class PasswordResetServiceTest extends TestCase
|
||||
{
|
||||
/** @var PasswordResetRepositoryInterface&MockObject */
|
||||
private PasswordResetRepositoryInterface $resetRepository;
|
||||
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/** @var MailServiceInterface&MockObject */
|
||||
private MailServiceInterface $mailService;
|
||||
|
||||
/** @var LoginAttemptRepositoryInterface&MockObject */
|
||||
private LoginAttemptRepositoryInterface $loginAttemptRepository;
|
||||
|
||||
private PasswordResetApplicationService $service;
|
||||
|
||||
/** @var TransactionManagerInterface&MockObject */
|
||||
private TransactionManagerInterface $transactionManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->resetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||||
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
|
||||
$this->mailService = $this->createMock(MailServiceInterface::class);
|
||||
$this->transactionManager = $this->createMock(TransactionManagerInterface::class);
|
||||
$this->loginAttemptRepository = $this->createMock(LoginAttemptRepositoryInterface::class);
|
||||
|
||||
$this->service = new PasswordResetApplicationService(
|
||||
new RequestPasswordReset(
|
||||
$this->resetRepository,
|
||||
$this->userRepository,
|
||||
$this->mailService,
|
||||
new PasswordResetTokenPolicy(),
|
||||
$this->loginAttemptRepository,
|
||||
new LoginRateLimitPolicy(),
|
||||
),
|
||||
new ValidatePasswordResetToken($this->resetRepository, $this->userRepository),
|
||||
new ResetPassword(
|
||||
$this->resetRepository,
|
||||
$this->userRepository,
|
||||
$this->transactionManager,
|
||||
new PasswordPolicy(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function testRequestResetUnknownEmailReturnsSilently(): void
|
||||
{
|
||||
$this->userRepository->method('findByEmail')->willReturn(null);
|
||||
$this->mailService->expects($this->never())->method('send');
|
||||
$this->resetRepository->expects($this->never())->method('create');
|
||||
|
||||
$this->service->requestReset('inconnu@example.com', 'https://app.exemple.com');
|
||||
}
|
||||
|
||||
public function testRequestResetInvalidatesPreviousTokens(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
|
||||
$this->userRepository->method('findByEmail')->willReturn($user);
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('invalidateByUserId')
|
||||
->with($user->getId());
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('create');
|
||||
$this->mailService->expects($this->once())
|
||||
->method('send');
|
||||
|
||||
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
|
||||
}
|
||||
|
||||
public function testRequestResetCreatesTokenInDatabase(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
|
||||
$this->userRepository->method('findByEmail')->willReturn($user);
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('invalidateByUserId');
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('create')
|
||||
->with($user->getId(), $this->callback('is_string'), $this->callback('is_string'));
|
||||
$this->mailService->expects($this->once())
|
||||
->method('send');
|
||||
|
||||
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
|
||||
}
|
||||
|
||||
public function testRequestResetSendsEmailWithCorrectAddress(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
|
||||
$this->userRepository->method('findByEmail')->willReturn($user);
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('invalidateByUserId');
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('create');
|
||||
|
||||
$this->mailService->expects($this->once())
|
||||
->method('send')
|
||||
->with(
|
||||
'alice@example.com',
|
||||
$this->callback('is_string'),
|
||||
'@Identity/emails/password-reset.twig',
|
||||
$this->callback('is_array'),
|
||||
);
|
||||
|
||||
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
|
||||
}
|
||||
|
||||
public function testRequestResetUrlContainsToken(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
|
||||
$this->userRepository->method('findByEmail')->willReturn($user);
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('invalidateByUserId');
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('create');
|
||||
|
||||
$this->mailService->expects($this->once())
|
||||
->method('send')
|
||||
->with(
|
||||
$this->anything(),
|
||||
$this->anything(),
|
||||
$this->anything(),
|
||||
$this->callback(function (array $context): bool {
|
||||
return isset($context['resetUrl'])
|
||||
&& str_contains($context['resetUrl'], '/password/reset?token=');
|
||||
}),
|
||||
);
|
||||
|
||||
$this->service->requestReset('alice@example.com', 'https://app.exemple.com');
|
||||
}
|
||||
|
||||
public function testValidateTokenMissingToken(): void
|
||||
{
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('findActiveByHash')
|
||||
->willReturn(null);
|
||||
|
||||
$result = $this->service->validateToken('tokeninexistant');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testValidateTokenExpiredToken(): void
|
||||
{
|
||||
$tokenRaw = 'montokenbrut';
|
||||
$tokenHash = hash('sha256', $tokenRaw);
|
||||
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('findActiveByHash')
|
||||
->with($tokenHash)
|
||||
->willReturn([
|
||||
'user_id' => 1,
|
||||
'token_hash' => $tokenHash,
|
||||
'expires_at' => date('Y-m-d H:i:s', time() - 3600),
|
||||
'used_at' => null,
|
||||
]);
|
||||
|
||||
$result = $this->service->validateToken($tokenRaw);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testValidateTokenValidToken(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
$tokenRaw = 'montokenbrut';
|
||||
$tokenHash = hash('sha256', $tokenRaw);
|
||||
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('findActiveByHash')
|
||||
->with($tokenHash)
|
||||
->willReturn([
|
||||
'user_id' => $user->getId(),
|
||||
'token_hash' => $tokenHash,
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
|
||||
'used_at' => null,
|
||||
]);
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with($user->getId())
|
||||
->willReturn($user);
|
||||
|
||||
$result = $this->service->validateToken($tokenRaw);
|
||||
|
||||
$this->assertSame($user, $result);
|
||||
}
|
||||
|
||||
public function testValidateTokenDeletedUserReturnsNull(): void
|
||||
{
|
||||
$tokenRaw = 'token-valide-mais-user-supprime';
|
||||
$tokenHash = hash('sha256', $tokenRaw);
|
||||
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('findActiveByHash')
|
||||
->with($tokenHash)
|
||||
->willReturn([
|
||||
'user_id' => 999,
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
|
||||
'used_at' => null,
|
||||
]);
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with(999)
|
||||
->willReturn(null);
|
||||
|
||||
$result = $this->service->validateToken($tokenRaw);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testResetPasswordInvalidToken(): void
|
||||
{
|
||||
$this->transactionManager->expects($this->once())
|
||||
->method('run')
|
||||
->willReturnCallback(function (callable $operation): mixed {
|
||||
return $operation();
|
||||
});
|
||||
$this->resetRepository->expects($this->once())->method('consumeActiveToken')->willReturn(null);
|
||||
|
||||
$this->expectException(InvalidResetTokenException::class);
|
||||
$this->expectExceptionMessageMatches('/invalide ou a expiré/');
|
||||
|
||||
$this->service->resetPassword('tokeninvalide', 'nouveaumdp12');
|
||||
}
|
||||
|
||||
public function testResetPasswordTooShortPasswordThrowsWeakPasswordException(): void
|
||||
{
|
||||
$this->expectException(WeakPasswordException::class);
|
||||
|
||||
$this->service->resetPassword('montokenbrut', '12345678901');
|
||||
}
|
||||
|
||||
public function testResetPasswordPreservesWhitespace(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
$tokenRaw = 'montokenbrut';
|
||||
$tokenHash = hash('sha256', $tokenRaw);
|
||||
$newPassword = ' nouveaumdp12 ';
|
||||
|
||||
$this->transactionManager->expects($this->once())
|
||||
->method('run')
|
||||
->willReturnCallback(function (callable $operation): mixed {
|
||||
return $operation();
|
||||
});
|
||||
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('consumeActiveToken')
|
||||
->with($tokenHash, $this->callback('is_string'))
|
||||
->willReturn([
|
||||
'user_id' => $user->getId(),
|
||||
'token_hash' => $tokenHash,
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
|
||||
'used_at' => null,
|
||||
]);
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with($user->getId())
|
||||
->willReturn($user);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('updatePassword')
|
||||
->with($user->getId(), $this->callback(static function (string $hash) use ($newPassword): bool {
|
||||
return password_verify($newPassword, $hash)
|
||||
&& !password_verify(trim($newPassword), $hash);
|
||||
}));
|
||||
|
||||
$this->service->resetPassword($tokenRaw, $newPassword);
|
||||
}
|
||||
|
||||
public function testResetPasswordUpdatesPasswordAndConsumesToken(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
$tokenRaw = 'montokenbrut';
|
||||
$tokenHash = hash('sha256', $tokenRaw);
|
||||
|
||||
$this->transactionManager->expects($this->once())
|
||||
->method('run')
|
||||
->willReturnCallback(function (callable $operation): mixed {
|
||||
return $operation();
|
||||
});
|
||||
|
||||
$this->resetRepository->expects($this->once())
|
||||
->method('consumeActiveToken')
|
||||
->with($tokenHash, $this->callback('is_string'))
|
||||
->willReturn([
|
||||
'user_id' => $user->getId(),
|
||||
'token_hash' => $tokenHash,
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
|
||||
'used_at' => null,
|
||||
]);
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('findById')
|
||||
->with($user->getId())
|
||||
->willReturn($user);
|
||||
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('updatePassword')
|
||||
->with($user->getId(), $this->callback('is_string'));
|
||||
|
||||
$this->service->resetPassword($tokenRaw, 'nouveaumdp12');
|
||||
}
|
||||
|
||||
private function makeUser(): User
|
||||
{
|
||||
return new User(
|
||||
1,
|
||||
'alice',
|
||||
'alice@example.com',
|
||||
password_hash('motdepasse12', PASSWORD_BCRYPT),
|
||||
);
|
||||
}
|
||||
}
|
||||
449
tests/Identity/UserControllerTest.php
Normal file
449
tests/Identity/UserControllerTest.php
Normal file
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\UserServiceInterface;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Exception\DuplicateEmailException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\DuplicateUsernameException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
|
||||
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
|
||||
use Netig\Netslim\Identity\UI\Http\UserController;
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
|
||||
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour UserController.
|
||||
*
|
||||
* Couvre les 5 actions publiques :
|
||||
* - index() : rendu de la liste
|
||||
* - showCreate() : rendu du formulaire
|
||||
* - create() : mismatch, username dupliqué, email dupliqué, mot de passe faible, succès
|
||||
* - updateRole() : introuvable, propre rôle, cible admin, rôle invalide, succès
|
||||
* - delete() : introuvable, cible admin, soi-même, succès
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class UserControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
/** @var UserServiceInterface&MockObject */
|
||||
private UserServiceInterface $userService;
|
||||
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
/** @var SessionManagerInterface&MockObject */
|
||||
private SessionManagerInterface $sessionManager;
|
||||
|
||||
private RolePolicy $rolePolicy;
|
||||
|
||||
private UserController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->userService = $this->createMock(UserServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
$this->rolePolicy = new RolePolicy();
|
||||
|
||||
$this->controller = new UserController(
|
||||
$this->view,
|
||||
$this->userService,
|
||||
$this->flash,
|
||||
$this->sessionManager,
|
||||
$this->rolePolicy,
|
||||
);
|
||||
}
|
||||
|
||||
// ── index ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* index() doit rendre la vue avec la liste des utilisateurs.
|
||||
*/
|
||||
public function testIndexRendersWithUserList(): void
|
||||
{
|
||||
$this->userService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20));
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Identity/admin/index.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->index($this->makeGet('/admin/users'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── showCreate ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* showCreate() doit rendre le formulaire de création.
|
||||
*/
|
||||
public function testShowCreateRendersForm(): void
|
||||
{
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Identity/admin/form.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->showCreate($this->makeGet('/admin/users/create'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── create ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() doit rediriger avec une erreur si les mots de passe ne correspondent pas.
|
||||
*/
|
||||
public function testCreateRedirectsWhenPasswordMismatch(): void
|
||||
{
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', 'Les mots de passe ne correspondent pas');
|
||||
|
||||
$req = $this->makePost('/admin/users/create', [
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'pass1',
|
||||
'password_confirm' => 'pass2',
|
||||
]);
|
||||
$res = $this->controller->create($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users/create');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() ne doit pas appeler userService si les mots de passe ne correspondent pas.
|
||||
*/
|
||||
public function testCreateDoesNotCallServiceOnMismatch(): void
|
||||
{
|
||||
$this->userService->expects($this->never())->method('create');
|
||||
$this->flash->method('set');
|
||||
|
||||
$req = $this->makePost('/admin/users/create', [
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'aaa',
|
||||
'password_confirm' => 'bbb',
|
||||
]);
|
||||
$this->controller->create($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit rediriger avec une erreur si le nom d'utilisateur est déjà pris.
|
||||
*/
|
||||
public function testCreateRedirectsOnDuplicateUsername(): void
|
||||
{
|
||||
$this->userService->method('create')
|
||||
->willThrowException(new DuplicateUsernameException('alice'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', $this->stringContains("nom d'utilisateur est déjà pris"));
|
||||
|
||||
$req = $this->makePost('/admin/users/create', [
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirm' => 'password123',
|
||||
]);
|
||||
$res = $this->controller->create($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users/create');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit rediriger avec une erreur si l'email est déjà utilisé.
|
||||
*/
|
||||
public function testCreateRedirectsOnDuplicateEmail(): void
|
||||
{
|
||||
$this->userService->method('create')
|
||||
->willThrowException(new DuplicateEmailException('alice@example.com'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', $this->stringContains('e-mail est déjà utilisée'));
|
||||
|
||||
$req = $this->makePost('/admin/users/create', [
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirm' => 'password123',
|
||||
]);
|
||||
$res = $this->controller->create($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users/create');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit rediriger avec une erreur si le mot de passe est trop court.
|
||||
*/
|
||||
public function testCreateRedirectsOnWeakPassword(): void
|
||||
{
|
||||
$this->userService->method('create')
|
||||
->willThrowException(new WeakPasswordException());
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', $this->stringContains('12 caractères'));
|
||||
|
||||
$req = $this->makePost('/admin/users/create', [
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'short',
|
||||
'password_confirm' => 'short',
|
||||
]);
|
||||
$res = $this->controller->create($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users/create');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit flasher un succès et rediriger vers /admin/users en cas de succès.
|
||||
*/
|
||||
public function testCreateRedirectsToUsersListOnSuccess(): void
|
||||
{
|
||||
$this->userService->method('create')->willReturn($this->makeUser(99, 'alice', User::ROLE_USER));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_success', $this->stringContains('alice'));
|
||||
|
||||
$req = $this->makePost('/admin/users/create', [
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirm' => 'password123',
|
||||
]);
|
||||
$res = $this->controller->create($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit forcer le rôle 'user' si un rôle admin est soumis dans le formulaire.
|
||||
*/
|
||||
public function testCreateForcesRoleUserWhenAdminRoleSubmitted(): void
|
||||
{
|
||||
$this->userService->expects($this->once())
|
||||
->method('create')
|
||||
->with('alice', 'alice@example.com', 'password123', User::ROLE_USER);
|
||||
|
||||
$this->flash->method('set');
|
||||
|
||||
$req = $this->makePost('/admin/users/create', [
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirm' => 'password123',
|
||||
'role' => User::ROLE_ADMIN, // rôle injecté par l'attaquant
|
||||
]);
|
||||
$this->controller->create($req, $this->makeResponse());
|
||||
}
|
||||
|
||||
// ── updateRole ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* updateRole() doit rediriger avec une erreur si l'utilisateur est introuvable.
|
||||
*/
|
||||
public function testUpdateRoleRedirectsWhenUserNotFound(): void
|
||||
{
|
||||
$this->userService->method('findById')->willReturn(null);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', 'Utilisateur introuvable');
|
||||
|
||||
$res = $this->controller->updateRole(
|
||||
$this->makePost('/admin/users/role/99', ['role' => User::ROLE_EDITOR]),
|
||||
$this->makeResponse(),
|
||||
['id' => '99'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* updateRole() doit rediriger avec une erreur si l'admin tente de changer son propre rôle.
|
||||
*/
|
||||
public function testUpdateRoleRedirectsWhenAdminTriesToChangeOwnRole(): void
|
||||
{
|
||||
$user = $this->makeUser(1, 'admin', User::ROLE_USER);
|
||||
$this->userService->method('findById')->willReturn($user);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1); // même ID
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', $this->stringContains('propre rôle'));
|
||||
|
||||
$res = $this->controller->updateRole(
|
||||
$this->makePost('/admin/users/role/1', ['role' => User::ROLE_EDITOR]),
|
||||
$this->makeResponse(),
|
||||
['id' => '1'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* updateRole() doit rediriger avec une erreur si l'utilisateur cible est déjà admin.
|
||||
*/
|
||||
public function testUpdateRoleRedirectsWhenTargetIsAdmin(): void
|
||||
{
|
||||
$user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN);
|
||||
$this->userService->method('findById')->willReturn($user);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', $this->stringContains("administrateur ne peut pas être modifié"));
|
||||
|
||||
$res = $this->controller->updateRole(
|
||||
$this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]),
|
||||
$this->makeResponse(),
|
||||
['id' => '2'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* updateRole() doit rediriger avec une erreur si le rôle soumis est invalide.
|
||||
*/
|
||||
public function testUpdateRoleRedirectsOnInvalidRole(): void
|
||||
{
|
||||
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
|
||||
$this->userService->method('findById')->willReturn($user);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', 'Rôle invalide');
|
||||
|
||||
$res = $this->controller->updateRole(
|
||||
$this->makePost('/admin/users/role/2', ['role' => 'superuser']),
|
||||
$this->makeResponse(),
|
||||
['id' => '2'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* updateRole() doit appeler userService et rediriger avec succès.
|
||||
*/
|
||||
public function testUpdateRoleRedirectsWithSuccessFlash(): void
|
||||
{
|
||||
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
|
||||
$this->userService->method('findById')->willReturn($user);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->userService->expects($this->once())->method('updateRole')->with(2, User::ROLE_EDITOR);
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_success', $this->stringContains('bob'));
|
||||
|
||||
$res = $this->controller->updateRole(
|
||||
$this->makePost('/admin/users/role/2', ['role' => User::ROLE_EDITOR]),
|
||||
$this->makeResponse(),
|
||||
['id' => '2'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
// ── delete ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() doit rediriger avec une erreur si l'utilisateur est introuvable.
|
||||
*/
|
||||
public function testDeleteRedirectsWhenUserNotFound(): void
|
||||
{
|
||||
$this->userService->method('findById')->willReturn(null);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', 'Utilisateur introuvable');
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/users/delete/99'),
|
||||
$this->makeResponse(),
|
||||
['id' => '99'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit rediriger avec une erreur si la cible est administrateur.
|
||||
*/
|
||||
public function testDeleteRedirectsWhenTargetIsAdmin(): void
|
||||
{
|
||||
$user = $this->makeUser(2, 'superadmin', User::ROLE_ADMIN);
|
||||
$this->userService->method('findById')->willReturn($user);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', $this->stringContains('administrateur ne peut pas être supprimé'));
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/users/delete/2'),
|
||||
$this->makeResponse(),
|
||||
['id' => '2'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit rediriger avec une erreur si l'admin tente de supprimer son propre compte.
|
||||
*/
|
||||
public function testDeleteRedirectsWhenAdminTriesToDeleteOwnAccount(): void
|
||||
{
|
||||
$user = $this->makeUser(1, 'alice', User::ROLE_USER);
|
||||
$this->userService->method('findById')->willReturn($user);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1); // même ID
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_error', $this->stringContains('propre compte'));
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/users/delete/1'),
|
||||
$this->makeResponse(),
|
||||
['id' => '1'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit appeler userService et rediriger avec succès.
|
||||
*/
|
||||
public function testDeleteRedirectsWithSuccessFlash(): void
|
||||
{
|
||||
$user = $this->makeUser(2, 'bob', User::ROLE_USER);
|
||||
$this->userService->method('findById')->willReturn($user);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$this->userService->expects($this->once())->method('delete')->with(2);
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('user_success', $this->stringContains('bob'));
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/users/delete/2'),
|
||||
$this->makeResponse(),
|
||||
['id' => '2'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/users');
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée un utilisateur de test avec les paramètres minimaux.
|
||||
*/
|
||||
private function makeUser(int $id, string $username, string $role): User
|
||||
{
|
||||
return new User($id, $username, "{$username}@example.com", password_hash('secret', PASSWORD_BCRYPT), $role);
|
||||
}
|
||||
}
|
||||
356
tests/Identity/UserRepositoryTest.php
Normal file
356
tests/Identity/UserRepositoryTest.php
Normal file
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Infrastructure\PdoUserRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PdoUserRepository.
|
||||
*
|
||||
* Vérifie que chaque méthode du dépôt construit le bon SQL,
|
||||
* lie les bons paramètres et retourne les bonnes valeurs.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class UserRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PdoUserRepository $repository;
|
||||
|
||||
/**
|
||||
* Données représentant une ligne utilisateur en base de données.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $rowAlice;
|
||||
|
||||
/**
|
||||
* Initialise le mock PDO, le dépôt et les données de test avant chaque test.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->repository = new PdoUserRepository($this->db);
|
||||
|
||||
$this->rowAlice = [
|
||||
'id' => 1,
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
|
||||
'role' => User::ROLE_USER,
|
||||
'created_at' => '2024-01-01 00:00:00',
|
||||
];
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
private function stmtForRead(array $rows = [], array|false $row = false): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetchAll')->willReturn($rows);
|
||||
$stmt->method('fetch')->willReturn($row);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
private function stmtForWrite(): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── findAll ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findAll() doit retourner un tableau vide si aucun utilisateur n'existe.
|
||||
*/
|
||||
public function testFindAllReturnsEmptyArrayWhenNone(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
$this->db->method('query')->willReturn($stmt);
|
||||
|
||||
$this->assertSame([], $this->repository->findAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* findAll() doit retourner un tableau d'instances User hydratées.
|
||||
*/
|
||||
public function testFindAllReturnsUserInstances(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([$this->rowAlice]);
|
||||
$this->db->method('query')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findAll();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertInstanceOf(User::class, $result[0]);
|
||||
$this->assertSame('alice', $result[0]->getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* findAll() interroge bien la table `users`.
|
||||
*/
|
||||
public function testFindAllRequestsUsersQuery(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('query')
|
||||
->with($this->stringContains('FROM users'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->repository->findAll();
|
||||
}
|
||||
|
||||
|
||||
// ── findById ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findById() doit retourner null si aucun utilisateur ne correspond à cet identifiant.
|
||||
*/
|
||||
public function testFindByIdReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findById(99));
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() doit retourner une instance User hydratée si l'utilisateur existe.
|
||||
*/
|
||||
public function testFindByIdReturnsUserWhenFound(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: $this->rowAlice);
|
||||
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findById(1);
|
||||
|
||||
$this->assertInstanceOf(User::class, $result);
|
||||
$this->assertSame(1, $result->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() doit exécuter avec le bon identifiant.
|
||||
*/
|
||||
public function testFindByIdQueriesWithCorrectId(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(42, $params, true)));
|
||||
|
||||
$this->repository->findById(42);
|
||||
}
|
||||
|
||||
|
||||
// ── findByUsername ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findByUsername() doit retourner null si le nom d'utilisateur est introuvable.
|
||||
*/
|
||||
public function testFindByUsernameReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findByUsername('inconnu'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByUsername() doit retourner une instance User si le nom est trouvé.
|
||||
*/
|
||||
public function testFindByUsernameReturnsUserWhenFound(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: $this->rowAlice);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findByUsername('alice');
|
||||
|
||||
$this->assertInstanceOf(User::class, $result);
|
||||
$this->assertSame('alice', $result->getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* findByUsername() doit exécuter avec le bon nom d'utilisateur.
|
||||
*/
|
||||
public function testFindByUsernameQueriesWithCorrectName(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array('alice', $params, true)));
|
||||
|
||||
$this->repository->findByUsername('alice');
|
||||
}
|
||||
|
||||
|
||||
// ── findByEmail ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findByEmail() doit retourner null si l'adresse e-mail est introuvable.
|
||||
*/
|
||||
public function testFindByEmailReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findByEmail('inconnu@example.com'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByEmail() doit retourner une instance User si l'e-mail est trouvé.
|
||||
*/
|
||||
public function testFindByEmailReturnsUserWhenFound(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: $this->rowAlice);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findByEmail('alice@example.com');
|
||||
|
||||
$this->assertInstanceOf(User::class, $result);
|
||||
$this->assertSame('alice@example.com', $result->getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* findByEmail() doit exécuter avec la bonne adresse e-mail.
|
||||
*/
|
||||
public function testFindByEmailQueriesWithCorrectEmail(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array('alice@example.com', $params, true)));
|
||||
|
||||
$this->repository->findByEmail('alice@example.com');
|
||||
}
|
||||
|
||||
|
||||
// ── create ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() doit préparer un INSERT sur la table 'users' avec les bonnes données.
|
||||
*/
|
||||
public function testCreateCallsInsertWithCorrectData(): void
|
||||
{
|
||||
$user = User::fromArray($this->rowAlice);
|
||||
$stmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->expects($this->once())->method('prepare')
|
||||
->with($this->stringContains('INSERT INTO users'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data) use ($user): bool {
|
||||
return $data[':username'] === $user->getUsername()
|
||||
&& $data[':email'] === $user->getEmail()
|
||||
&& $data[':password_hash'] === $user->getPasswordHash()
|
||||
&& $data[':role'] === $user->getRole()
|
||||
&& isset($data[':created_at']);
|
||||
}));
|
||||
|
||||
$this->db->method('lastInsertId')->willReturn('1');
|
||||
|
||||
$this->repository->create($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit retourner l'identifiant généré par la base de données.
|
||||
*/
|
||||
public function testCreateReturnsGeneratedId(): void
|
||||
{
|
||||
$user = User::fromArray($this->rowAlice);
|
||||
$stmt = $this->stmtForWrite();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
$this->db->method('lastInsertId')->willReturn('42');
|
||||
|
||||
$this->assertSame(42, $this->repository->create($user));
|
||||
}
|
||||
|
||||
|
||||
// ── updatePassword ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* updatePassword() doit préparer un UPDATE avec le nouveau hash et le bon identifiant.
|
||||
*/
|
||||
public function testUpdatePasswordCallsUpdateWithCorrectHash(): void
|
||||
{
|
||||
$newHash = password_hash('nouveaumdp', PASSWORD_BCRYPT);
|
||||
$stmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->expects($this->once())->method('prepare')
|
||||
->with($this->stringContains('UPDATE users'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array($newHash, $params, true) && in_array(1, $params, true)));
|
||||
|
||||
$this->repository->updatePassword(1, $newHash);
|
||||
}
|
||||
|
||||
|
||||
// ── updateRole ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* updateRole() doit préparer un UPDATE avec le bon rôle et le bon identifiant.
|
||||
*/
|
||||
public function testUpdateRoleCallsUpdateWithCorrectRole(): void
|
||||
{
|
||||
$stmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->expects($this->once())->method('prepare')
|
||||
->with($this->stringContains('UPDATE users'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(User::ROLE_EDITOR, $params, true) && in_array(1, $params, true)));
|
||||
|
||||
$this->repository->updateRole(1, User::ROLE_EDITOR);
|
||||
}
|
||||
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() doit préparer un DELETE avec le bon identifiant.
|
||||
*/
|
||||
public function testDeleteCallsDeleteWithCorrectId(): void
|
||||
{
|
||||
$stmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('DELETE FROM users'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(7, $params, true)));
|
||||
|
||||
$this->repository->delete(7);
|
||||
}
|
||||
}
|
||||
304
tests/Identity/UserServiceTest.php
Normal file
304
tests/Identity/UserServiceTest.php
Normal file
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use Netig\Netslim\Identity\Application\UseCase\AdminDeleteUser;
|
||||
use Netig\Netslim\Identity\Application\UseCase\AdminUpdateUserRole;
|
||||
use Netig\Netslim\Identity\Application\UseCase\CreateUser;
|
||||
use Netig\Netslim\Identity\Application\UseCase\DeleteUser;
|
||||
use Netig\Netslim\Identity\Application\UseCase\UpdateUserRole;
|
||||
use Netig\Netslim\Identity\Application\UserApplicationService;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use Netig\Netslim\Identity\Domain\Exception\DuplicateEmailException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\DuplicateUsernameException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\InvalidRoleException;
|
||||
use Netig\Netslim\Identity\Domain\Exception\WeakPasswordException;
|
||||
use Netig\Netslim\Identity\Domain\Policy\PasswordPolicy;
|
||||
use Netig\Netslim\Identity\Domain\Policy\RolePolicy;
|
||||
use Netig\Netslim\Identity\Domain\Repository\UserRepositoryInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour UserApplicationService.
|
||||
*
|
||||
* Vérifie la création de compte : normalisation, unicité du nom d'utilisateur
|
||||
* et de l'email, validation de la complexité du mot de passe.
|
||||
* Les dépendances sont remplacées par des mocks via leurs interfaces pour
|
||||
* isoler le service.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class UserServiceTest extends TestCase
|
||||
{
|
||||
/** @var UserRepositoryInterface&MockObject */
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
private UserApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
|
||||
$rolePolicy = new RolePolicy();
|
||||
$passwordPolicy = new PasswordPolicy();
|
||||
$this->service = new UserApplicationService(
|
||||
$this->userRepository,
|
||||
new CreateUser($this->userRepository, $rolePolicy, $passwordPolicy),
|
||||
new UpdateUserRole($this->userRepository, $rolePolicy),
|
||||
new DeleteUser($this->userRepository),
|
||||
new AdminUpdateUserRole($this->userRepository, $rolePolicy),
|
||||
new AdminDeleteUser($this->userRepository),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── create ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() doit créer et retourner un utilisateur avec les bonnes données.
|
||||
*/
|
||||
public function testCreateUserWithValidData(): void
|
||||
{
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
$this->userRepository->method('findByEmail')->willReturn(null);
|
||||
$this->userRepository->expects($this->once())->method('create');
|
||||
|
||||
$user = $this->service->create('Alice', 'alice@example.com', 'motdepasse12');
|
||||
|
||||
$this->assertSame('alice', $user->getUsername());
|
||||
$this->assertSame('alice@example.com', $user->getEmail());
|
||||
$this->assertSame(User::ROLE_USER, $user->getRole());
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit normaliser le nom d'utilisateur et l'email en minuscules.
|
||||
*/
|
||||
public function testCreateUserNormalizesToLowercase(): void
|
||||
{
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
$this->userRepository->method('findByEmail')->willReturn(null);
|
||||
$this->userRepository->method('create');
|
||||
|
||||
$user = $this->service->create(' ALICE ', ' ALICE@EXAMPLE.COM ', 'motdepasse12');
|
||||
|
||||
$this->assertSame('alice', $user->getUsername());
|
||||
$this->assertSame('alice@example.com', $user->getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit lever DuplicateUsernameException si le nom est déjà pris.
|
||||
*/
|
||||
public function testCreateUserDuplicateUsernameThrowsDuplicateUsernameException(): void
|
||||
{
|
||||
$existingUser = $this->makeUser('alice', 'alice@example.com');
|
||||
$this->userRepository->method('findByUsername')->willReturn($existingUser);
|
||||
|
||||
$this->expectException(DuplicateUsernameException::class);
|
||||
|
||||
$this->service->create('alice', 'autre@example.com', 'motdepasse12');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit lever DuplicateEmailException si l'email est déjà utilisé.
|
||||
*/
|
||||
public function testCreateUserDuplicateEmailThrowsDuplicateEmailException(): void
|
||||
{
|
||||
$existingUser = $this->makeUser('bob', 'alice@example.com');
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
$this->userRepository->method('findByEmail')->willReturn($existingUser);
|
||||
|
||||
$this->expectException(DuplicateEmailException::class);
|
||||
|
||||
$this->service->create('newuser', 'alice@example.com', 'motdepasse12');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit lever WeakPasswordException si le mot de passe est trop court.
|
||||
*/
|
||||
public function testCreateUserTooShortPasswordThrowsWeakPasswordException(): void
|
||||
{
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
$this->userRepository->method('findByEmail')->willReturn(null);
|
||||
|
||||
$this->expectException(WeakPasswordException::class);
|
||||
|
||||
$this->service->create('alice', 'alice@example.com', '12345678901');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() avec exactement 12 caractères de mot de passe doit réussir.
|
||||
*/
|
||||
public function testCreateUserMinimumPasswordLength(): void
|
||||
{
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
$this->userRepository->method('findByEmail')->willReturn(null);
|
||||
$this->userRepository->method('create');
|
||||
|
||||
$user = $this->service->create('alice', 'alice@example.com', '123456789012');
|
||||
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit stocker un hash bcrypt, jamais le mot de passe en clair.
|
||||
*/
|
||||
public function testCreateUserPasswordIsHashed(): void
|
||||
{
|
||||
$plainPassword = 'motdepasse12';
|
||||
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
$this->userRepository->method('findByEmail')->willReturn(null);
|
||||
$this->userRepository->method('create');
|
||||
|
||||
$user = $this->service->create('alice', 'alice@example.com', $plainPassword);
|
||||
|
||||
$this->assertNotSame($plainPassword, $user->getPasswordHash());
|
||||
$this->assertTrue(password_verify($plainPassword, $user->getPasswordHash()));
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit conserver exactement les espaces du mot de passe saisi.
|
||||
*/
|
||||
public function testCreateUserPreservesPasswordWhitespace(): void
|
||||
{
|
||||
$plainPassword = ' motdepasse12 ';
|
||||
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
$this->userRepository->method('findByEmail')->willReturn(null);
|
||||
$this->userRepository->method('create');
|
||||
|
||||
$user = $this->service->create('alice', 'alice@example.com', $plainPassword);
|
||||
|
||||
$this->assertTrue(password_verify($plainPassword, $user->getPasswordHash()));
|
||||
$this->assertFalse(password_verify(trim($plainPassword), $user->getPasswordHash()));
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit attribuer le rôle passé en paramètre.
|
||||
*/
|
||||
public function testCreateUserWithEditorRole(): void
|
||||
{
|
||||
$this->userRepository->method('findByUsername')->willReturn(null);
|
||||
$this->userRepository->method('findByEmail')->willReturn(null);
|
||||
$this->userRepository->method('create');
|
||||
|
||||
$user = $this->service->create('alice', 'alice@example.com', 'motdepasse12', User::ROLE_EDITOR);
|
||||
|
||||
$this->assertSame(User::ROLE_EDITOR, $user->getRole());
|
||||
}
|
||||
|
||||
|
||||
// ── findAll ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findAll() délègue au repository et retourne la liste.
|
||||
*/
|
||||
public function testFindAllDelegatesToRepository(): void
|
||||
{
|
||||
$users = [$this->makeUser('alice', 'alice@example.com')];
|
||||
$this->userRepository->method('findAll')->willReturn($users);
|
||||
|
||||
$this->assertSame($users, $this->service->findAll());
|
||||
}
|
||||
|
||||
|
||||
// ── findById ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findById() retourne null si l'utilisateur est introuvable.
|
||||
*/
|
||||
public function testFindByIdReturnsNullWhenMissing(): void
|
||||
{
|
||||
$this->userRepository->method('findById')->willReturn(null);
|
||||
|
||||
$this->assertNull($this->service->findById(99));
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() retourne l'utilisateur trouvé.
|
||||
*/
|
||||
public function testFindByIdReturnsUser(): void
|
||||
{
|
||||
$user = $this->makeUser('alice', 'alice@example.com');
|
||||
$this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($user);
|
||||
|
||||
$this->assertSame($user, $this->service->findById(1));
|
||||
}
|
||||
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() délègue la suppression au repository.
|
||||
*/
|
||||
public function testDeleteDelegatesToRepository(): void
|
||||
{
|
||||
$this->userRepository->expects($this->once())->method('findById')->with(5)->willReturn($this->makeUser('alice', 'alice@example.com'));
|
||||
$this->userRepository->expects($this->once())->method('delete')->with(5);
|
||||
|
||||
$this->service->delete(5);
|
||||
}
|
||||
|
||||
|
||||
// ── updateRole ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* updateRole() doit déléguer au repository avec le rôle validé.
|
||||
*/
|
||||
public function testUpdateRoleDelegatesToRepository(): void
|
||||
{
|
||||
$this->userRepository->expects($this->once())->method('findById')->with(3)->willReturn($this->makeUser('alice', 'alice@example.com'));
|
||||
$this->userRepository->expects($this->once())
|
||||
->method('updateRole')
|
||||
->with(3, User::ROLE_EDITOR);
|
||||
|
||||
$this->service->updateRole(3, User::ROLE_EDITOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateRole() doit lever InvalidArgumentException pour un rôle inconnu.
|
||||
*/
|
||||
public function testUpdateRoleThrowsOnInvalidRole(): void
|
||||
{
|
||||
$this->userRepository->expects($this->never())->method('updateRole');
|
||||
|
||||
$this->expectException(InvalidRoleException::class);
|
||||
|
||||
$this->service->updateRole(1, 'superadmin');
|
||||
}
|
||||
|
||||
/**
|
||||
* updateRole() accepte uniquement les rôles attribuables depuis l'interface.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('validRolesProvider')]
|
||||
public function testUpdateRoleAcceptsAllValidRoles(string $role): void
|
||||
{
|
||||
$this->userRepository->expects($this->once())->method('findById')->with(1)->willReturn($this->makeUser('alice', 'alice@example.com'));
|
||||
$this->userRepository->expects($this->once())->method('updateRole')->with(1, $role);
|
||||
|
||||
$this->service->updateRole(1, $role);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function validRolesProvider(): array
|
||||
{
|
||||
return [
|
||||
'user' => [User::ROLE_USER],
|
||||
'editor' => [User::ROLE_EDITOR],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée un utilisateur de test avec un hash bcrypt du mot de passe fourni.
|
||||
*/
|
||||
private function makeUser(string $username, string $email): User
|
||||
{
|
||||
return new User(1, $username, $email, password_hash('motdepasse12', PASSWORD_BCRYPT));
|
||||
}
|
||||
}
|
||||
233
tests/Identity/UserTest.php
Normal file
233
tests/Identity/UserTest.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Identity;
|
||||
|
||||
use DateTime;
|
||||
use InvalidArgumentException;
|
||||
use Netig\Netslim\Identity\Domain\Entity\User;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour le modèle User.
|
||||
*
|
||||
* Vérifie la construction, la validation, les accesseurs
|
||||
* et l'hydratation depuis un tableau de base de données.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class UserTest extends TestCase
|
||||
{
|
||||
// ── Construction valide ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Un utilisateur construit avec des données valides ne doit pas lever d'exception.
|
||||
*/
|
||||
public function testValidConstruction(): void
|
||||
{
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret123', PASSWORD_BCRYPT));
|
||||
|
||||
$this->assertSame(1, $user->getId());
|
||||
$this->assertSame('alice', $user->getUsername());
|
||||
$this->assertSame('alice@example.com', $user->getEmail());
|
||||
$this->assertSame(User::ROLE_USER, $user->getRole());
|
||||
}
|
||||
|
||||
/**
|
||||
* Le rôle par défaut doit être 'user'.
|
||||
*/
|
||||
public function testDefaultRole(): void
|
||||
{
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
|
||||
$this->assertSame(User::ROLE_USER, $user->getRole());
|
||||
$this->assertFalse($user->isAdmin());
|
||||
$this->assertFalse($user->isEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur avec le rôle 'admin' doit être reconnu comme administrateur.
|
||||
*/
|
||||
public function testAdminRole(): void
|
||||
{
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_ADMIN);
|
||||
|
||||
$this->assertTrue($user->isAdmin());
|
||||
$this->assertFalse($user->isEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur avec le rôle 'editor' doit être reconnu comme éditeur.
|
||||
*/
|
||||
public function testEditorRole(): void
|
||||
{
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_EDITOR);
|
||||
|
||||
$this->assertFalse($user->isAdmin());
|
||||
$this->assertTrue($user->isEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Une date de création explicite doit être conservée.
|
||||
*/
|
||||
public function testExplicitCreationDate(): void
|
||||
{
|
||||
$date = new DateTime('2024-01-15 10:00:00');
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), User::ROLE_USER, $date);
|
||||
|
||||
$this->assertEquals($date, $user->getCreatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sans date explicite, la date de création doit être définie à maintenant.
|
||||
*/
|
||||
public function testDefaultCreationDate(): void
|
||||
{
|
||||
$before = new DateTime();
|
||||
$user = new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
$after = new DateTime();
|
||||
|
||||
$this->assertGreaterThanOrEqual($before, $user->getCreatedAt());
|
||||
$this->assertLessThanOrEqual($after, $user->getCreatedAt());
|
||||
}
|
||||
|
||||
|
||||
// ── Validation — nom d'utilisateur ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Un nom d'utilisateur de moins de 3 caractères doit lever une exception.
|
||||
*/
|
||||
public function testUsernameTooShort(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessageMatches('/3 caractères/');
|
||||
|
||||
new User(1, 'ab', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Un nom d'utilisateur de plus de 50 caractères doit lever une exception.
|
||||
*/
|
||||
public function testUsernameTooLong(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessageMatches('/50 caractères/');
|
||||
|
||||
new User(1, str_repeat('a', 51), 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Un nom d'utilisateur de exactement 3 caractères doit être accepté.
|
||||
*/
|
||||
public function testUsernameMinimumLength(): void
|
||||
{
|
||||
$user = new User(1, 'ali', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
|
||||
$this->assertSame('ali', $user->getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* Un nom d'utilisateur de exactement 50 caractères doit être accepté.
|
||||
*/
|
||||
public function testUsernameMaximumLength(): void
|
||||
{
|
||||
$username = str_repeat('a', 50);
|
||||
$user = new User(1, $username, 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT));
|
||||
|
||||
$this->assertSame($username, $user->getUsername());
|
||||
}
|
||||
|
||||
|
||||
// ── Validation — email ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Un email invalide doit lever une exception.
|
||||
*/
|
||||
public function testInvalidEmail(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessageMatches('/email/i');
|
||||
|
||||
new User(1, 'alice', 'pas-un-email', password_hash('secret', PASSWORD_BCRYPT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Un email vide doit lever une exception.
|
||||
*/
|
||||
public function testEmptyEmail(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new User(1, 'alice', '', password_hash('secret', PASSWORD_BCRYPT));
|
||||
}
|
||||
|
||||
|
||||
// ── Validation — hash du mot de passe ──────────────────────────
|
||||
|
||||
/**
|
||||
* Un hash de mot de passe vide doit lever une exception.
|
||||
*/
|
||||
public function testEmptyPasswordHash(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessageMatches('/hash/i');
|
||||
|
||||
new User(1, 'alice', 'alice@example.com', '');
|
||||
}
|
||||
|
||||
|
||||
// ── Validation — rôle ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Un rôle invalide doit lever une exception.
|
||||
*/
|
||||
public function testInvalidRole(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessageMatches('/rôle/i');
|
||||
|
||||
new User(1, 'alice', 'alice@example.com', password_hash('secret', PASSWORD_BCRYPT), 'superadmin');
|
||||
}
|
||||
|
||||
|
||||
// ── Hydratation depuis un tableau ──────────────────────────────
|
||||
|
||||
/**
|
||||
* fromArray() doit hydrater correctement l'utilisateur depuis une ligne de base de données.
|
||||
*/
|
||||
public function testFromArray(): void
|
||||
{
|
||||
$hash = password_hash('secret', PASSWORD_BCRYPT);
|
||||
|
||||
$user = User::fromArray([
|
||||
'id' => 42,
|
||||
'username' => 'bob',
|
||||
'email' => 'bob@example.com',
|
||||
'password_hash' => $hash,
|
||||
'role' => 'editor',
|
||||
'created_at' => '2024-06-01 12:00:00',
|
||||
]);
|
||||
|
||||
$this->assertSame(42, $user->getId());
|
||||
$this->assertSame('bob', $user->getUsername());
|
||||
$this->assertSame('bob@example.com', $user->getEmail());
|
||||
$this->assertSame($hash, $user->getPasswordHash());
|
||||
$this->assertSame('editor', $user->getRole());
|
||||
$this->assertTrue($user->isEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* fromArray() avec une date absente ne doit pas lever d'exception.
|
||||
*/
|
||||
public function testFromArrayWithoutDate(): void
|
||||
{
|
||||
$user = User::fromArray([
|
||||
'id' => 1,
|
||||
'username' => 'alice',
|
||||
'email' => 'alice@example.com',
|
||||
'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(DateTime::class, $user->getCreatedAt());
|
||||
}
|
||||
}
|
||||
56
tests/Kernel/BootstrapTest.php
Normal file
56
tests/Kernel/BootstrapTest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Bootstrap;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use ReflectionProperty;
|
||||
use Slim\Factory\AppFactory;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class BootstrapTest extends TestCase
|
||||
{
|
||||
public function testInitializeInfrastructureReturnsPreloadedContainer(): void
|
||||
{
|
||||
$bootstrap = Bootstrap::create();
|
||||
$container = $this->createStub(ContainerInterface::class);
|
||||
|
||||
$this->setPrivate($bootstrap, 'container', $container);
|
||||
|
||||
self::assertSame($container, $bootstrap->initializeInfrastructure());
|
||||
self::assertSame($container, $bootstrap->getContainer());
|
||||
}
|
||||
|
||||
public function testCreateHttpAppReturnsPreloadedApp(): void
|
||||
{
|
||||
$bootstrap = Bootstrap::create();
|
||||
$app = AppFactory::create();
|
||||
|
||||
$this->setPrivate($bootstrap, 'app', $app);
|
||||
|
||||
self::assertSame($app, $bootstrap->createHttpApp());
|
||||
}
|
||||
|
||||
public function testInitializeReturnsPreloadedApp(): void
|
||||
{
|
||||
$bootstrap = Bootstrap::create();
|
||||
$container = $this->createStub(ContainerInterface::class);
|
||||
$app = AppFactory::create();
|
||||
|
||||
$this->setPrivate($bootstrap, 'container', $container);
|
||||
$this->setPrivate($bootstrap, 'app', $app);
|
||||
|
||||
self::assertSame($app, $bootstrap->initialize());
|
||||
}
|
||||
|
||||
private function setPrivate(Bootstrap $bootstrap, string $property, mixed $value): void
|
||||
{
|
||||
$reflection = new ReflectionProperty($bootstrap, $property);
|
||||
$reflection->setAccessible(true);
|
||||
$reflection->setValue($bootstrap, $value);
|
||||
}
|
||||
}
|
||||
36
tests/Kernel/ClientIpResolverCoverageTest.php
Normal file
36
tests/Kernel/ClientIpResolverCoverageTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class ClientIpResolverCoverageTest extends TestCase
|
||||
{
|
||||
public function testResolveReturnsRemoteAddrWhenTrustedProxyHasNoForwardedHeader(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
|
||||
'REMOTE_ADDR' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
$resolver = new ClientIpResolver(['127.0.0.1']);
|
||||
|
||||
self::assertSame('127.0.0.1', $resolver->resolve($request));
|
||||
}
|
||||
|
||||
public function testResolveTrimsForwardedIpWhenProxyWildcardIsTrusted(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
|
||||
'REMOTE_ADDR' => '10.0.0.1',
|
||||
'HTTP_X_FORWARDED_FOR' => ' 203.0.113.77 , 198.51.100.12',
|
||||
]);
|
||||
|
||||
$resolver = new ClientIpResolver(['*']);
|
||||
|
||||
self::assertSame('203.0.113.77', $resolver->resolve($request));
|
||||
}
|
||||
}
|
||||
58
tests/Kernel/ClientIpResolverTest.php
Normal file
58
tests/Kernel/ClientIpResolverTest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class ClientIpResolverTest extends TestCase
|
||||
{
|
||||
public function testResolveReturnsDefaultWhenRemoteAddrMissing(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/');
|
||||
$resolver = new ClientIpResolver();
|
||||
|
||||
self::assertSame('0.0.0.0', $resolver->resolve($request));
|
||||
}
|
||||
|
||||
public function testResolveReturnsRemoteAddrWhenProxyNotTrusted(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
|
||||
'REMOTE_ADDR' => '10.0.0.1',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.10',
|
||||
]);
|
||||
|
||||
$resolver = new ClientIpResolver(['127.0.0.1']);
|
||||
|
||||
self::assertSame('10.0.0.1', $resolver->resolve($request));
|
||||
}
|
||||
|
||||
public function testResolveReturnsForwardedIpWhenProxyTrusted(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
|
||||
'REMOTE_ADDR' => '127.0.0.1',
|
||||
'HTTP_X_FORWARDED_FOR' => '203.0.113.10, 198.51.100.12',
|
||||
]);
|
||||
|
||||
$resolver = new ClientIpResolver(['127.0.0.1']);
|
||||
|
||||
self::assertSame('203.0.113.10', $resolver->resolve($request));
|
||||
}
|
||||
|
||||
public function testResolveFallsBackToRemoteAddrWhenForwardedIpInvalid(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/', [
|
||||
'REMOTE_ADDR' => '127.0.0.1',
|
||||
'HTTP_X_FORWARDED_FOR' => 'not-an-ip',
|
||||
]);
|
||||
|
||||
$resolver = new ClientIpResolver(['*']);
|
||||
|
||||
self::assertSame('127.0.0.1', $resolver->resolve($request));
|
||||
}
|
||||
}
|
||||
184
tests/Kernel/ContainerWiringIntegrationTest.php
Normal file
184
tests/Kernel/ContainerWiringIntegrationTest.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Netig\Netslim\AuditLog\Application\AuditLogServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\AuthServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\PasswordResetServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\UserServiceInterface;
|
||||
use Netig\Netslim\Identity\UI\Http\AccountController;
|
||||
use Netig\Netslim\Identity\UI\Http\AuthController;
|
||||
use Netig\Netslim\Identity\UI\Http\PasswordResetController;
|
||||
use Netig\Netslim\Identity\UI\Http\UserController;
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Twig\AppExtension;
|
||||
use Netig\Netslim\Kernel\Runtime\Http\MiddlewareRegistrar;
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleRegistry;
|
||||
use Netig\Netslim\Kernel\Runtime\RuntimePaths;
|
||||
use Netig\Netslim\Media\Application\MediaServiceInterface;
|
||||
use Netig\Netslim\Media\UI\Http\MediaController;
|
||||
use Netig\Netslim\Notifications\Application\NotificationServiceInterface;
|
||||
use Netig\Netslim\Settings\Application\SettingsServiceInterface;
|
||||
use Netig\Netslim\Taxonomy\Application\TaxonomyServiceInterface;
|
||||
use Netig\Netslim\Taxonomy\Contracts\TaxonomyReaderInterface;
|
||||
use Netig\Netslim\Taxonomy\UI\Http\TaxonomyController;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Factory\AppFactory;
|
||||
use Slim\Views\Twig;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
final class ContainerWiringIntegrationTest extends TestCase
|
||||
{
|
||||
private const ENV_DEFAULTS = [
|
||||
'APP_ENV' => 'development',
|
||||
'APP_URL' => 'http://localhost',
|
||||
'APP_NAME' => 'NETslim Test',
|
||||
'ADMIN_USERNAME' => 'admin',
|
||||
'ADMIN_EMAIL' => 'admin@example.test',
|
||||
'ADMIN_PASSWORD' => 'secret123456',
|
||||
'MAIL_HOST' => 'localhost',
|
||||
'MAIL_PORT' => '1025',
|
||||
'MAIL_USERNAME' => '',
|
||||
'MAIL_PASSWORD' => '',
|
||||
'MAIL_ENCRYPTION' => 'tls',
|
||||
'MAIL_FROM' => 'noreply@example.test',
|
||||
'MAIL_FROM_NAME' => 'NETslim Test',
|
||||
'TIMEZONE' => 'UTC',
|
||||
];
|
||||
|
||||
/** @var array<string, string|null> */
|
||||
private array $envBackup = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application');
|
||||
ModuleRegistry::reset();
|
||||
foreach (self::ENV_DEFAULTS as $key => $value) {
|
||||
$this->envBackup[$key] = $_ENV[$key] ?? null;
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RuntimePaths::resetApplicationRoot();
|
||||
RuntimePaths::resetProjectRoot();
|
||||
ModuleRegistry::reset();
|
||||
foreach (self::ENV_DEFAULTS as $key => $_) {
|
||||
$previous = $this->envBackup[$key] ?? null;
|
||||
|
||||
if ($previous === null) {
|
||||
unset($_ENV[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$_ENV[$key] = $previous;
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{class-string}> */
|
||||
public static function containerServicesProvider(): iterable
|
||||
{
|
||||
yield 'auth service' => [AuthServiceInterface::class];
|
||||
yield 'password reset service' => [PasswordResetServiceInterface::class];
|
||||
yield 'authorization service' => [AuthorizationServiceInterface::class];
|
||||
yield 'settings service' => [SettingsServiceInterface::class];
|
||||
yield 'audit log service' => [AuditLogServiceInterface::class];
|
||||
yield 'notification service' => [NotificationServiceInterface::class];
|
||||
yield 'taxonomy service' => [TaxonomyServiceInterface::class];
|
||||
yield 'media service' => [MediaServiceInterface::class];
|
||||
yield 'taxonomy reader' => [TaxonomyReaderInterface::class];
|
||||
yield 'user service' => [UserServiceInterface::class];
|
||||
yield 'auth controller' => [AuthController::class];
|
||||
yield 'account controller' => [AccountController::class];
|
||||
yield 'password reset controller' => [PasswordResetController::class];
|
||||
yield 'taxonomy controller' => [TaxonomyController::class];
|
||||
yield 'media controller' => [MediaController::class];
|
||||
yield 'user controller' => [UserController::class];
|
||||
yield 'twig' => [Twig::class];
|
||||
yield 'app extension' => [AppExtension::class];
|
||||
}
|
||||
|
||||
#[DataProvider('containerServicesProvider')]
|
||||
public function testContainerResolvesExpectedServices(string $id): void
|
||||
{
|
||||
$container = $this->buildContainer();
|
||||
|
||||
$resolved = $container->get($id);
|
||||
|
||||
self::assertInstanceOf($id, $resolved);
|
||||
}
|
||||
|
||||
public function testTwigFactoryRegistersModuleTemplateNamespaces(): void
|
||||
{
|
||||
$container = $this->buildContainer();
|
||||
$twig = $container->get(Twig::class);
|
||||
$loader = $twig->getEnvironment()->getLoader();
|
||||
|
||||
self::assertInstanceOf(FilesystemLoader::class, $loader);
|
||||
|
||||
foreach (ModuleRegistry::modules() as $module) {
|
||||
foreach ($module->templateNamespaces() as $namespace => $templatePath) {
|
||||
self::assertContains(
|
||||
$templatePath,
|
||||
$loader->getPaths($namespace),
|
||||
sprintf('Le namespace Twig "%s" doit pointer vers "%s".', $namespace, $templatePath),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testMiddlewareRegistrationLoadsModuleTwigExtensions(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
session_id('netslim-container-wiring-test');
|
||||
session_start();
|
||||
|
||||
try {
|
||||
$container = $this->buildContainer();
|
||||
AppFactory::setContainer($container);
|
||||
$app = AppFactory::create();
|
||||
$registrar = new MiddlewareRegistrar();
|
||||
|
||||
$registrar->register($app, $container);
|
||||
|
||||
$extensions = $container->get(Twig::class)->getEnvironment()->getExtensions();
|
||||
|
||||
$assertions = 0;
|
||||
|
||||
foreach (ModuleRegistry::modules() as $module) {
|
||||
foreach ($module->twigExtensions() as $twigExtensionClass) {
|
||||
self::assertArrayHasKey(
|
||||
$twigExtensionClass,
|
||||
$extensions,
|
||||
sprintf("L'extension Twig \"%s\" doit être chargée par MiddlewareRegistrar.", $twigExtensionClass),
|
||||
);
|
||||
self::assertInstanceOf($twigExtensionClass, $extensions[$twigExtensionClass]);
|
||||
++$assertions;
|
||||
}
|
||||
}
|
||||
self::assertGreaterThanOrEqual(0, $assertions);
|
||||
} finally {
|
||||
session_write_close();
|
||||
}
|
||||
}
|
||||
|
||||
private function buildContainer(): Container
|
||||
{
|
||||
$builder = new ContainerBuilder();
|
||||
$builder->useAutowiring(true);
|
||||
$builder->addDefinitions(dirname(__DIR__, 2) . '/src/Kernel/Runtime/DI/container.php');
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
}
|
||||
42
tests/Kernel/DatabaseReadinessTest.php
Normal file
42
tests/Kernel/DatabaseReadinessTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\DatabaseNotProvisionedException;
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\DatabaseReadiness;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DatabaseReadinessTest extends TestCase
|
||||
{
|
||||
public function testAssertProvisionedFailsWhenModuleTablesAreMissing(): void
|
||||
{
|
||||
$db = new PDO('sqlite::memory:');
|
||||
$db->exec('CREATE TABLE migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT, run_at TEXT)');
|
||||
|
||||
$this->expectException(DatabaseNotProvisionedException::class);
|
||||
$this->expectExceptionMessage('users');
|
||||
|
||||
DatabaseReadiness::assertProvisioned($db);
|
||||
}
|
||||
|
||||
public function testAssertProvisionedAcceptsCompleteCoreSchema(): void
|
||||
{
|
||||
$db = new PDO('sqlite::memory:');
|
||||
|
||||
$db->exec('CREATE TABLE migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, version TEXT, run_at TEXT)');
|
||||
$db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, email TEXT, password_hash TEXT, role TEXT, session_version INTEGER, created_at TEXT)');
|
||||
$db->exec('CREATE TABLE password_resets (id INTEGER PRIMARY KEY, user_id INTEGER, token_hash TEXT, expires_at TEXT, used_at TEXT, created_at TEXT)');
|
||||
$db->exec('CREATE TABLE rate_limits (scope TEXT, rate_key TEXT, attempts INTEGER, locked_until TEXT, updated_at TEXT)');
|
||||
$db->exec('CREATE TABLE settings (setting_key TEXT PRIMARY KEY, setting_value TEXT, value_type TEXT, updated_at TEXT)');
|
||||
$db->exec('CREATE TABLE audit_log (id INTEGER PRIMARY KEY, action TEXT, resource_type TEXT, resource_id TEXT, actor_user_id INTEGER, context_json TEXT, created_at TEXT)');
|
||||
$db->exec('CREATE TABLE notification_dispatches (id INTEGER PRIMARY KEY, recipient TEXT, subject TEXT, template TEXT, status TEXT, notification_key TEXT, error_message TEXT, created_at TEXT, sent_at TEXT)');
|
||||
$db->exec('CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT, slug TEXT)');
|
||||
$db->exec('CREATE TABLE media (id INTEGER PRIMARY KEY, filename TEXT, url TEXT, hash TEXT, user_id INTEGER, created_at TEXT)');
|
||||
|
||||
DatabaseReadiness::assertProvisioned($db);
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
94
tests/Kernel/DateParserTest.php
Normal file
94
tests/Kernel/DateParserTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use DateTime;
|
||||
use Netig\Netslim\Kernel\Support\Util\DateParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour DateParser::parse().
|
||||
*
|
||||
* Couvre la conversion de valeurs brutes de base de données en DateTime,
|
||||
* ainsi que les cas silencieux (null, chaîne vide, valeur invalide).
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class DateParserTest extends TestCase
|
||||
{
|
||||
// ── Valeurs valides ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Une date au format SQLite standard doit produire un DateTime correct.
|
||||
*/
|
||||
public function testStandardSqliteDate(): void
|
||||
{
|
||||
$result = DateParser::parse('2024-06-15 10:30:00');
|
||||
|
||||
$this->assertInstanceOf(DateTime::class, $result);
|
||||
$this->assertSame('2024-06-15', $result->format('Y-m-d'));
|
||||
$this->assertSame('10:30:00', $result->format('H:i:s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une date ISO 8601 doit être correctement parsée.
|
||||
*/
|
||||
public function testIso8601Date(): void
|
||||
{
|
||||
$result = DateParser::parse('2024-01-01T00:00:00');
|
||||
|
||||
$this->assertInstanceOf(DateTime::class, $result);
|
||||
$this->assertSame('2024-01-01', $result->format('Y-m-d'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une date seule (sans heure) doit être parsée correctement.
|
||||
*/
|
||||
public function testDateOnly(): void
|
||||
{
|
||||
$result = DateParser::parse('2024-12-31');
|
||||
|
||||
$this->assertInstanceOf(DateTime::class, $result);
|
||||
$this->assertSame('2024-12-31', $result->format('Y-m-d'));
|
||||
}
|
||||
|
||||
|
||||
// ── Valeurs silencieuses — doit retourner null sans exception ────
|
||||
|
||||
/**
|
||||
* null doit retourner null.
|
||||
*/
|
||||
public function testNullReturnsNull(): void
|
||||
{
|
||||
$this->assertNull(DateParser::parse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une chaîne vide doit retourner null.
|
||||
*/
|
||||
public function testEmptyStringReturnsNull(): void
|
||||
{
|
||||
$this->assertNull(DateParser::parse(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une valeur non parseable doit retourner null sans lever d'exception.
|
||||
*/
|
||||
public function testInvalidValueReturnsNull(): void
|
||||
{
|
||||
$this->assertNull(DateParser::parse('pas-une-date'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Un entier doit être interprété comme un timestamp si valide,
|
||||
* ou retourner null si la conversion échoue.
|
||||
* Ici on vérifie simplement qu'aucune exception n'est levée.
|
||||
*/
|
||||
public function testIntegerThrowsNoException(): void
|
||||
{
|
||||
$result = DateParser::parse(0);
|
||||
// Pas d'assertion sur la valeur — on vérifie juste la robustesse
|
||||
$this->assertTrue($result === null || $result instanceof DateTime);
|
||||
}
|
||||
}
|
||||
126
tests/Kernel/DefaultErrorHandlerTest.php
Normal file
126
tests/Kernel/DefaultErrorHandlerTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\DatabaseNotProvisionedException;
|
||||
use Netig\Netslim\Kernel\Runtime\Http\DefaultErrorHandler;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
use Slim\Psr7\Response;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class DefaultErrorHandlerTest extends TestCase
|
||||
{
|
||||
/** @var ResponseFactoryInterface&MockObject */
|
||||
private ResponseFactoryInterface $responseFactory;
|
||||
|
||||
/** @var Twig&MockObject */
|
||||
private Twig $twig;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->responseFactory = $this->createMock(ResponseFactoryInterface::class);
|
||||
$this->responseFactory
|
||||
->method('createResponse')
|
||||
->willReturnCallback(static fn (int $statusCode): Response => new Response($statusCode));
|
||||
|
||||
$this->twig = $this->createMock(Twig::class);
|
||||
}
|
||||
|
||||
public function testInvokeRendersFriendlyNotFoundPage(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/missing');
|
||||
$exception = new HttpNotFoundException($request);
|
||||
|
||||
$this->twig
|
||||
->expects(self::once())
|
||||
->method('render')
|
||||
->willReturnCallback(function (Response $response, string $template, array $data): Response {
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertSame('@Kernel/error.twig', $template);
|
||||
self::assertSame(404, $data['status']);
|
||||
self::assertSame('La page demandée est introuvable.', $data['message']);
|
||||
|
||||
$response->getBody()->write('404: ' . $data['message']);
|
||||
|
||||
return $response;
|
||||
});
|
||||
|
||||
$handler = new DefaultErrorHandler($this->responseFactory, $this->twig, false);
|
||||
$response = $handler($request, $exception, false, true, true);
|
||||
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertStringContainsString('404: La page demandée est introuvable.', (string) $response->getBody());
|
||||
}
|
||||
|
||||
public function testInvokeRendersDatabaseProvisioningErrorAs503(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/');
|
||||
$exception = new DatabaseNotProvisionedException('La base de données doit être provisionnée.');
|
||||
|
||||
$this->twig
|
||||
->expects(self::once())
|
||||
->method('render')
|
||||
->willReturnCallback(function (Response $response, string $template, array $data): Response {
|
||||
self::assertSame(503, $response->getStatusCode());
|
||||
self::assertSame('@Kernel/error.twig', $template);
|
||||
self::assertSame(503, $data['status']);
|
||||
self::assertSame('La base de données doit être provisionnée.', $data['message']);
|
||||
|
||||
$response->getBody()->write('503: ' . $data['message']);
|
||||
|
||||
return $response;
|
||||
});
|
||||
|
||||
$handler = new DefaultErrorHandler($this->responseFactory, $this->twig, false);
|
||||
$response = $handler($request, $exception, false, true, true);
|
||||
|
||||
self::assertSame(503, $response->getStatusCode());
|
||||
self::assertStringContainsString('503: La base de données doit être provisionnée.', (string) $response->getBody());
|
||||
}
|
||||
|
||||
public function testInvokeDoesNotRethrowHttpExceptionsInDevelopment(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/missing');
|
||||
$exception = new HttpNotFoundException($request);
|
||||
|
||||
$this->twig
|
||||
->expects(self::once())
|
||||
->method('render')
|
||||
->willReturnCallback(function (Response $response, string $template, array $data): Response {
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertSame('@Kernel/error.twig', $template);
|
||||
self::assertSame(404, $data['status']);
|
||||
self::assertSame('La page demandée est introuvable.', $data['message']);
|
||||
|
||||
$response->getBody()->write('404 dev: ' . $data['message']);
|
||||
|
||||
return $response;
|
||||
});
|
||||
|
||||
$handler = new DefaultErrorHandler($this->responseFactory, $this->twig, true);
|
||||
$response = $handler($request, $exception, true, true, true);
|
||||
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertStringContainsString('404 dev: La page demandée est introuvable.', (string) $response->getBody());
|
||||
}
|
||||
|
||||
public function testInvokeRethrowsUnexpectedExceptionsInDevelopment(): void
|
||||
{
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/boom');
|
||||
$exception = new \RuntimeException('boom');
|
||||
|
||||
$this->twig->expects(self::never())->method('render');
|
||||
|
||||
$handler = new DefaultErrorHandler($this->responseFactory, $this->twig, true);
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
$handler($request, $exception, true, true, true);
|
||||
}
|
||||
}
|
||||
108
tests/Kernel/ErrorHandlerConfiguratorTest.php
Normal file
108
tests/Kernel/ErrorHandlerConfiguratorTest.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\DatabaseNotProvisionedException;
|
||||
use Netig\Netslim\Kernel\Runtime\Http\ErrorHandlerConfigurator;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\Factory\AppFactory;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class ErrorHandlerConfiguratorTest extends TestCase
|
||||
{
|
||||
/** @var Twig&MockObject */
|
||||
private Twig $twig;
|
||||
|
||||
/** @var ContainerInterface&MockObject */
|
||||
private ContainerInterface $container;
|
||||
|
||||
private ?string $originalAppEnv = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->originalAppEnv = $_ENV['APP_ENV'] ?? null;
|
||||
$_ENV['APP_ENV'] = 'production';
|
||||
|
||||
$this->twig = $this->createMock(Twig::class);
|
||||
$logger = $this->createStub(LoggerInterface::class);
|
||||
|
||||
$this->container = $this->createMock(ContainerInterface::class);
|
||||
$this->container
|
||||
->method('get')
|
||||
->willReturnMap([
|
||||
[LoggerInterface::class, $logger],
|
||||
[Twig::class, $this->twig],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->originalAppEnv === null) {
|
||||
unset($_ENV['APP_ENV']);
|
||||
} else {
|
||||
$_ENV['APP_ENV'] = $this->originalAppEnv;
|
||||
}
|
||||
}
|
||||
|
||||
public function testConfigureRegistersHandlerThatRenders404Responses(): void
|
||||
{
|
||||
$app = AppFactory::create();
|
||||
|
||||
$this->twig
|
||||
->expects(self::once())
|
||||
->method('render')
|
||||
->willReturnCallback(function ($response, string $template, array $data) {
|
||||
self::assertSame('@Kernel/error.twig', $template);
|
||||
self::assertSame(404, $data['status']);
|
||||
self::assertSame('La page demandée est introuvable.', $data['message']);
|
||||
|
||||
$response->getBody()->write('configured 404');
|
||||
|
||||
return $response;
|
||||
});
|
||||
|
||||
(new ErrorHandlerConfigurator())->configure($app, $this->container);
|
||||
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/missing');
|
||||
$response = $app->handle($request);
|
||||
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertStringContainsString('configured 404', (string) $response->getBody());
|
||||
}
|
||||
|
||||
public function testConfigureRegistersHandlerThatRenders503Responses(): void
|
||||
{
|
||||
$app = AppFactory::create();
|
||||
$app->get('/db-check', function (): never {
|
||||
throw new DatabaseNotProvisionedException('Provisionnement requis');
|
||||
});
|
||||
|
||||
$this->twig
|
||||
->expects(self::once())
|
||||
->method('render')
|
||||
->willReturnCallback(function ($response, string $template, array $data) {
|
||||
self::assertSame('@Kernel/error.twig', $template);
|
||||
self::assertSame(503, $data['status']);
|
||||
self::assertSame('Provisionnement requis', $data['message']);
|
||||
|
||||
$response->getBody()->write('configured 503');
|
||||
|
||||
return $response;
|
||||
});
|
||||
|
||||
(new ErrorHandlerConfigurator())->configure($app, $this->container);
|
||||
|
||||
$request = (new ServerRequestFactory())->createServerRequest('GET', '/db-check');
|
||||
$response = $app->handle($request);
|
||||
|
||||
self::assertSame(503, $response->getStatusCode());
|
||||
self::assertStringContainsString('configured 503', (string) $response->getBody());
|
||||
}
|
||||
}
|
||||
63
tests/Kernel/ExtensionTest.php
Normal file
63
tests/Kernel/ExtensionTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Twig\AppExtension;
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Twig\CsrfExtension;
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Twig\SessionExtension;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Csrf\Guard;
|
||||
use Slim\Psr7\Factory\ResponseFactory;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class ExtensionTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
public function testAppExtensionExposesAppUrl(): void
|
||||
{
|
||||
$extension = new AppExtension('https://example.test');
|
||||
|
||||
self::assertSame(['app_url' => 'https://example.test'], $extension->getGlobals());
|
||||
}
|
||||
|
||||
public function testSessionExtensionExposesSelectedSessionKeys(): void
|
||||
{
|
||||
$_SESSION = [
|
||||
'user_id' => 12,
|
||||
'username' => 'julien',
|
||||
'role' => 'admin',
|
||||
'flash' => ['notice' => 'x'],
|
||||
];
|
||||
|
||||
$extension = new SessionExtension();
|
||||
|
||||
self::assertSame([
|
||||
'session' => [
|
||||
'user_id' => 12,
|
||||
'username' => 'julien',
|
||||
'role' => 'admin',
|
||||
],
|
||||
], $extension->getGlobals());
|
||||
}
|
||||
|
||||
public function testCsrfExtensionExposesTokens(): void
|
||||
{
|
||||
$storage = [];
|
||||
$guard = new Guard(new ResponseFactory(), storage: $storage);
|
||||
$extension = new CsrfExtension($guard);
|
||||
$globals = $extension->getGlobals();
|
||||
|
||||
self::assertArrayHasKey('csrf', $globals);
|
||||
self::assertSame($guard->getTokenNameKey(), $globals['csrf']['keys']['name']);
|
||||
self::assertSame($guard->getTokenValueKey(), $globals['csrf']['keys']['value']);
|
||||
self::assertSame($guard->getTokenName(), $globals['csrf']['name']);
|
||||
self::assertSame($guard->getTokenValue(), $globals['csrf']['value']);
|
||||
}
|
||||
}
|
||||
29
tests/Kernel/FlashServiceConsumeTest.php
Normal file
29
tests/Kernel/FlashServiceConsumeTest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Flash\FlashService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class FlashServiceConsumeTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
public function testGetReturnsNullWhenMissingAndConsumesWhenPresent(): void
|
||||
{
|
||||
$flash = new FlashService();
|
||||
|
||||
self::assertNull($flash->get('missing'));
|
||||
|
||||
$flash->set('notice', 'Bonjour');
|
||||
self::assertSame('Bonjour', $flash->get('notice'));
|
||||
self::assertNull($flash->get('notice'));
|
||||
}
|
||||
}
|
||||
27
tests/Kernel/FlashServiceCoverageTest.php
Normal file
27
tests/Kernel/FlashServiceCoverageTest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Flash\FlashService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class FlashServiceCoverageTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
public function testGetCastsFalseToEmptyString(): void
|
||||
{
|
||||
$_SESSION['flash']['flag'] = false;
|
||||
|
||||
$flash = new FlashService();
|
||||
|
||||
self::assertSame('', $flash->get('flag'));
|
||||
self::assertArrayNotHasKey('flag', $_SESSION['flash']);
|
||||
}
|
||||
}
|
||||
45
tests/Kernel/FlashServiceTest.php
Normal file
45
tests/Kernel/FlashServiceTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Flash\FlashService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class FlashServiceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
public function testSetAndGetConsumesFlashMessage(): void
|
||||
{
|
||||
$flash = new FlashService();
|
||||
|
||||
$flash->set('notice', 'Bonjour');
|
||||
|
||||
self::assertSame('Bonjour', $flash->get('notice'));
|
||||
self::assertNull($flash->get('notice'));
|
||||
}
|
||||
|
||||
public function testGetCastsNonStringValueAndRemovesIt(): void
|
||||
{
|
||||
$_SESSION['flash']['count'] = 123;
|
||||
|
||||
$flash = new FlashService();
|
||||
|
||||
self::assertSame('123', $flash->get('count'));
|
||||
self::assertArrayNotHasKey('count', $_SESSION['flash']);
|
||||
}
|
||||
|
||||
public function testGetReturnsNullWhenMissing(): void
|
||||
{
|
||||
$flash = new FlashService();
|
||||
|
||||
self::assertNull($flash->get('missing'));
|
||||
}
|
||||
}
|
||||
27
tests/Kernel/HelperEdgeCasesTest.php
Normal file
27
tests/Kernel/HelperEdgeCasesTest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Request\ClientIpResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class HelperEdgeCasesTest extends TestCase
|
||||
{
|
||||
public function testClientIpResolverFallsBackToRemoteAddr(): void
|
||||
{
|
||||
$resolver = new ClientIpResolver([]);
|
||||
|
||||
$request = new ServerRequestFactory()->createServerRequest(
|
||||
'GET',
|
||||
'/',
|
||||
['REMOTE_ADDR' => '127.0.0.1'],
|
||||
);
|
||||
|
||||
self::assertSame('127.0.0.1', $resolver->resolve($request));
|
||||
}
|
||||
}
|
||||
36
tests/Kernel/HtmlPurifierFactoryTest.php
Normal file
36
tests/Kernel/HtmlPurifierFactoryTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Html\Infrastructure\HtmlPurifierFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class HtmlPurifierFactoryTest extends TestCase
|
||||
{
|
||||
public function testCreateBuildsPurifierAndSanitizesDangerousHtml(): void
|
||||
{
|
||||
$cacheDir = sys_get_temp_dir() . '/htmlpurifier-test-' . bin2hex(random_bytes(4));
|
||||
|
||||
try {
|
||||
$purifier = HtmlPurifierFactory::create($cacheDir);
|
||||
$result = $purifier->purify('<p style="text-align:center">ok</p><a href="javascript:alert(1)">x</a><img src="/media/image.webp" data-media-id="42" alt=""> https://example.test');
|
||||
|
||||
self::assertDirectoryExists($cacheDir);
|
||||
self::assertStringContainsString('text-align:center', $result);
|
||||
self::assertStringNotContainsString('javascript:', $result);
|
||||
self::assertStringContainsString('https://example.test', $result);
|
||||
self::assertStringContainsString('data-media-id="42"', $result);
|
||||
} finally {
|
||||
if (is_dir($cacheDir)) {
|
||||
foreach (glob($cacheDir . '/*') ?: [] as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
@rmdir($cacheDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
252
tests/Kernel/HtmlSanitizerTest.php
Normal file
252
tests/Kernel/HtmlSanitizerTest.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Html\Infrastructure\HtmlPurifierFactory;
|
||||
use Netig\Netslim\Kernel\Html\Infrastructure\HtmlSanitizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour HtmlSanitizer.
|
||||
*
|
||||
* Vérifie que HTMLPurifier supprime bien les contenus dangereux
|
||||
* (XSS, balises non autorisées, schémas URI interdits) et conserve
|
||||
* les balises légitimes produites par l'éditeur riche.
|
||||
*
|
||||
* Ces tests utilisent une vraie instance HTMLPurifier (pas de mock)
|
||||
* car c'est le comportement de purification lui-même qui est testé.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class HtmlSanitizerTest extends TestCase
|
||||
{
|
||||
private HtmlSanitizer $sanitizer;
|
||||
|
||||
/**
|
||||
* Crée une instance réelle de HtmlSanitizer avant chaque test.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
$purifier = HtmlPurifierFactory::create(sys_get_temp_dir() . '/htmlpurifier_tests');
|
||||
$this->sanitizer = new HtmlSanitizer($purifier);
|
||||
}
|
||||
|
||||
|
||||
// ── Balises autorisées ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Les balises de texte courantes doivent être conservées.
|
||||
*/
|
||||
public function testTextTagsPreserved(): void
|
||||
{
|
||||
$html = '<p>Un <strong>texte</strong> avec <em>emphase</em> et <u>soulignement</u>.</p>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('<strong>texte</strong>', $result);
|
||||
$this->assertStringContainsString('<em>emphase</em>', $result);
|
||||
$this->assertStringContainsString('<u>soulignement</u>', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les titres h1 à h6 doivent être conservés.
|
||||
*/
|
||||
public function testHeadingsPreserved(): void
|
||||
{
|
||||
$html = '<h1>Titre 1</h1><h2>Titre 2</h2><h3>Titre 3</h3>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('<h1>', $result);
|
||||
$this->assertStringContainsString('<h2>', $result);
|
||||
$this->assertStringContainsString('<h3>', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les listes ordonnées et non ordonnées doivent être conservées.
|
||||
*/
|
||||
public function testListsPreserved(): void
|
||||
{
|
||||
$html = '<ul><li>Item 1</li><li>Item 2</li></ul><ol><li>A</li></ol>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('<ul>', $result);
|
||||
$this->assertStringContainsString('<ol>', $result);
|
||||
$this->assertStringContainsString('<li>', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les liens avec href http/https doivent être conservés.
|
||||
*/
|
||||
public function testHttpLinksPreserved(): void
|
||||
{
|
||||
$html = '<a href="https://example.com">Lien</a>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('href="https://example.com"', $result);
|
||||
$this->assertStringContainsString('Lien', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les images avec src doivent être conservées.
|
||||
*/
|
||||
public function testImagesPreserved(): void
|
||||
{
|
||||
$html = '<img src="https://example.com/image.jpg" alt="Description">';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('<img', $result);
|
||||
$this->assertStringContainsString('src="https://example.com/image.jpg"', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'identifiant métier d'un média doit être conservé sur l'image.
|
||||
*/
|
||||
public function testImageDataMediaIdPreserved(): void
|
||||
{
|
||||
$html = '<img src="https://example.com/image.jpg" data-media-id="42" alt="Description">';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('data-media-id="42"', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les blocs de code doivent être conservés.
|
||||
*/
|
||||
public function testPreTagPreserved(): void
|
||||
{
|
||||
$html = '<pre>code ici</pre>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('<pre>', $result);
|
||||
}
|
||||
|
||||
|
||||
// ── Balises et attributs dangereux — suppression XSS ───────────
|
||||
|
||||
/**
|
||||
* Les balises <script> doivent être supprimées.
|
||||
*/
|
||||
public function testScriptTagRemoved(): void
|
||||
{
|
||||
$html = '<p>Texte</p><script>alert("xss")</script>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $result);
|
||||
$this->assertStringNotContainsString('alert(', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les attributs onclick et autres handlers JavaScript doivent être supprimés.
|
||||
*/
|
||||
public function testJavascriptAttributesRemoved(): void
|
||||
{
|
||||
$html = '<p onclick="alert(1)" onmouseover="evil()">Texte</p>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringNotContainsString('onclick', $result);
|
||||
$this->assertStringNotContainsString('onmouseover', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les liens javascript: doivent être supprimés.
|
||||
*/
|
||||
public function testJavascriptLinkRemoved(): void
|
||||
{
|
||||
$html = '<a href="javascript:alert(1)">Cliquez</a>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringNotContainsString('javascript:', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les liens data: doivent être supprimés.
|
||||
*/
|
||||
public function testDataLinkRemoved(): void
|
||||
{
|
||||
$html = '<a href="data:text/html,<script>alert(1)</script>">XSS</a>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringNotContainsString('data:', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* La balise <iframe> doit être supprimée.
|
||||
*/
|
||||
public function testIframeTagRemoved(): void
|
||||
{
|
||||
$html = '<iframe src="https://evil.com"></iframe>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringNotContainsString('<iframe', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* La balise <object> doit être supprimée.
|
||||
*/
|
||||
public function testObjectTagRemoved(): void
|
||||
{
|
||||
$html = '<object data="malware.swf"></object>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringNotContainsString('<object', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* La balise <form> doit être supprimée.
|
||||
*/
|
||||
public function testFormTagRemoved(): void
|
||||
{
|
||||
$html = '<form action="/steal"><input type="password"></form>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringNotContainsString('<form', $result);
|
||||
$this->assertStringNotContainsString('<input', $result);
|
||||
}
|
||||
|
||||
|
||||
// ── Cas limites ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Une chaîne vide doit retourner une chaîne vide (ou quasi-vide).
|
||||
*/
|
||||
public function testEmptyStringReturnsEmptyOrBlank(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('');
|
||||
|
||||
$this->assertSame('', trim($result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Du texte brut sans balises doit être conservé.
|
||||
*/
|
||||
public function testPlainTextWithoutTags(): void
|
||||
{
|
||||
$html = 'Bonjour le monde';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('Bonjour le monde', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les attributs CSS text-align doivent être conservés.
|
||||
*/
|
||||
public function testStyleTextAlignAttributePreserved(): void
|
||||
{
|
||||
$html = '<p style="text-align: center;">Centré</p>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringContainsString('text-align', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les propriétés CSS autres que text-align doivent être supprimées.
|
||||
*/
|
||||
public function testOtherCssPropertiesRemoved(): void
|
||||
{
|
||||
$html = '<p style="color: red; background: url(evil.php);">Texte</p>';
|
||||
$result = $this->sanitizer->sanitize($html);
|
||||
|
||||
$this->assertStringNotContainsString('color', $result);
|
||||
$this->assertStringNotContainsString('background', $result);
|
||||
}
|
||||
}
|
||||
155
tests/Kernel/InfrastructureBootstrapperTest.php
Normal file
155
tests/Kernel/InfrastructureBootstrapperTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Startup\InfrastructureBootstrapper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
final class InfrastructureBootstrapperTest extends TestCase
|
||||
{
|
||||
private string $workspace;
|
||||
|
||||
/** @var array<string, string|null> */
|
||||
private array $envBackup = [];
|
||||
|
||||
/** @var array<string, string|false> */
|
||||
private array $putenvBackup = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->workspace = sys_get_temp_dir() . '/netslim-bootstrapper-' . bin2hex(random_bytes(8));
|
||||
mkdir($this->workspace, 0777, true);
|
||||
|
||||
foreach (['APP_ENV', 'APP_URL', 'TIMEZONE'] as $key) {
|
||||
$this->envBackup[$key] = $_ENV[$key] ?? null;
|
||||
$this->putenvBackup[$key] = getenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
putenv($key);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->envBackup as $key => $value) {
|
||||
if ($value === null) {
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
} else {
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
|
||||
$previous = $this->putenvBackup[$key] ?? false;
|
||||
if ($previous === false) {
|
||||
putenv($key);
|
||||
} else {
|
||||
putenv($key . '=' . $previous);
|
||||
}
|
||||
}
|
||||
|
||||
$this->removeDirectory($this->workspace);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testBootstrapCreatesRuntimeDirectoriesLoadsEnvironmentAndMemoizesContainer(): void
|
||||
{
|
||||
$this->writeEnvFile([
|
||||
'APP_ENV=development',
|
||||
'APP_URL=http://localhost',
|
||||
'ADMIN_USERNAME=admin',
|
||||
'ADMIN_EMAIL=admin@example.test',
|
||||
'ADMIN_PASSWORD=secret123456',
|
||||
'TIMEZONE=Europe/Paris',
|
||||
]);
|
||||
$definitionsPath = $this->writeDefinitionsFile();
|
||||
$bootstrapper = new InfrastructureBootstrapper($this->workspace, $definitionsPath);
|
||||
|
||||
$container = $bootstrapper->bootstrap();
|
||||
$sameContainer = $bootstrapper->bootstrap();
|
||||
|
||||
self::assertInstanceOf(ContainerInterface::class, $container);
|
||||
self::assertSame($container, $sameContainer);
|
||||
self::assertSame('from-test-definitions', $container->get('bootstrap.flag'));
|
||||
self::assertSame('Europe/Paris', date_default_timezone_get());
|
||||
|
||||
foreach ([
|
||||
'var/cache/twig',
|
||||
'var/cache/htmlpurifier',
|
||||
'var/cache/di',
|
||||
'var/logs',
|
||||
'database',
|
||||
'public/media',
|
||||
] as $directory) {
|
||||
self::assertDirectoryExists($this->workspace . '/' . $directory);
|
||||
}
|
||||
}
|
||||
|
||||
public function testBootstrapFailsWhenEnvironmentFileIsMissing(): void
|
||||
{
|
||||
$bootstrapper = new InfrastructureBootstrapper($this->workspace, $this->writeDefinitionsFile());
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Fichier .env introuvable');
|
||||
|
||||
$bootstrapper->bootstrap();
|
||||
}
|
||||
|
||||
public function testBootstrapNoLongerRequiresIdentityProvisioningVariables(): void
|
||||
{
|
||||
$this->writeEnvFile([
|
||||
'APP_ENV=production',
|
||||
'APP_URL=https://example.test',
|
||||
]);
|
||||
$bootstrapper = new InfrastructureBootstrapper($this->workspace, $this->writeDefinitionsFile());
|
||||
|
||||
$container = $bootstrapper->bootstrap();
|
||||
|
||||
self::assertInstanceOf(ContainerInterface::class, $container);
|
||||
}
|
||||
|
||||
private function writeEnvFile(array $lines): void
|
||||
{
|
||||
file_put_contents($this->workspace . '/.env', implode(PHP_EOL, $lines) . PHP_EOL);
|
||||
}
|
||||
|
||||
private function writeDefinitionsFile(): string
|
||||
{
|
||||
$definitionsPath = $this->workspace . '/definitions.php';
|
||||
file_put_contents($definitionsPath, <<<'PHP'
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'bootstrap.flag' => 'from-test-definitions',
|
||||
];
|
||||
PHP);
|
||||
|
||||
return $definitionsPath;
|
||||
}
|
||||
|
||||
private function removeDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if ($fileInfo->isDir()) {
|
||||
rmdir($fileInfo->getPathname());
|
||||
continue;
|
||||
}
|
||||
|
||||
unlink($fileInfo->getPathname());
|
||||
}
|
||||
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
72
tests/Kernel/MailServiceTest.php
Normal file
72
tests/Kernel/MailServiceTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Mail\Infrastructure\MailService;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionMethod;
|
||||
use Slim\Views\Twig;
|
||||
use Twig\Loader\ArrayLoader;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class MailServiceTest extends TestCase
|
||||
{
|
||||
public function testCreateMailerUsesSslConfiguration(): void
|
||||
{
|
||||
$service = $this->makeService('ssl', 465);
|
||||
$mailer = $this->invokeCreateMailer($service);
|
||||
|
||||
self::assertSame('smtp', $mailer->Mailer);
|
||||
self::assertSame('smtp.example.test', $mailer->Host);
|
||||
self::assertTrue($mailer->SMTPAuth);
|
||||
self::assertSame('mailer-user', $mailer->Username);
|
||||
self::assertSame('mailer-pass', $mailer->Password);
|
||||
self::assertSame(PHPMailer::ENCRYPTION_SMTPS, $mailer->SMTPSecure);
|
||||
self::assertSame(465, $mailer->Port);
|
||||
self::assertSame(PHPMailer::CHARSET_UTF8, $mailer->CharSet);
|
||||
self::assertSame('no-reply@example.test', $mailer->From);
|
||||
self::assertSame('NETslim', $mailer->FromName);
|
||||
}
|
||||
|
||||
public function testCreateMailerUsesStartTlsWhenEncryptionIsNotSsl(): void
|
||||
{
|
||||
$service = $this->makeService('tls', 587);
|
||||
$mailer = $this->invokeCreateMailer($service);
|
||||
|
||||
self::assertSame(PHPMailer::ENCRYPTION_STARTTLS, $mailer->SMTPSecure);
|
||||
self::assertSame(587, $mailer->Port);
|
||||
}
|
||||
|
||||
private function makeService(string $encryption, int $port): MailService
|
||||
{
|
||||
$twig = new Twig(new ArrayLoader([
|
||||
'@Test/emails/test.twig' => '<p>Bonjour {{ name }}</p>',
|
||||
]));
|
||||
|
||||
return new MailService(
|
||||
$twig,
|
||||
'smtp.example.test',
|
||||
$port,
|
||||
'mailer-user',
|
||||
'mailer-pass',
|
||||
$encryption,
|
||||
'no-reply@example.test',
|
||||
'NETslim',
|
||||
);
|
||||
}
|
||||
|
||||
private function invokeCreateMailer(MailService $service): PHPMailer
|
||||
{
|
||||
$method = new ReflectionMethod($service, 'createMailer');
|
||||
$method->setAccessible(true);
|
||||
|
||||
/** @var PHPMailer $mailer */
|
||||
$mailer = $method->invoke($service);
|
||||
|
||||
return $mailer;
|
||||
}
|
||||
}
|
||||
82
tests/Kernel/MigratorTest.php
Normal file
82
tests/Kernel/MigratorTest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\Migrator;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour Migrator.
|
||||
*
|
||||
* Vérifie que run() crée la table de suivi, exécute uniquement les migrations
|
||||
* en attente puis agrège correctement les migrations du socle (`Identity`,
|
||||
* `Settings`, `AuditLog`, `Notifications`, `Taxonomy`, `Media`).
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MigratorTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testRunCreatesMigrationsTable(): void
|
||||
{
|
||||
Migrator::run($this->db);
|
||||
|
||||
$stmt = $this->db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'");
|
||||
self::assertNotFalse($stmt->fetchColumn(), 'La table migrations doit exister après run().');
|
||||
}
|
||||
|
||||
public function testRunIsIdempotent(): void
|
||||
{
|
||||
Migrator::run($this->db);
|
||||
Migrator::run($this->db);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testAlreadyAppliedMigrationIsSkipped(): void
|
||||
{
|
||||
Migrator::run($this->db);
|
||||
$before = $this->countMigrations();
|
||||
|
||||
$stmt = $this->db->prepare('INSERT INTO migrations (version, run_at) VALUES (:v, :r)');
|
||||
$stmt->execute([':v' => '999_future_migration', ':r' => date('Y-m-d H:i:s')]);
|
||||
|
||||
Migrator::run($this->db);
|
||||
$after = $this->countMigrations();
|
||||
|
||||
self::assertSame($before + 1, $after);
|
||||
}
|
||||
|
||||
public function testRunRecordsModuleMigrationsInTable(): void
|
||||
{
|
||||
Migrator::run($this->db);
|
||||
|
||||
self::assertGreaterThan(0, $this->countMigrations(), 'Au moins une migration du socle doit être enregistrée.');
|
||||
}
|
||||
|
||||
public function testRunCreatesCoreModuleTables(): void
|
||||
{
|
||||
Migrator::run($this->db);
|
||||
|
||||
foreach (['users', 'password_resets', 'rate_limits', 'settings', 'audit_log', 'notification_dispatches', 'categories', 'media'] as $table) {
|
||||
$stmt = $this->db->query(sprintf("SELECT name FROM sqlite_master WHERE type='table' AND name='%s'", $table));
|
||||
self::assertNotFalse($stmt->fetchColumn(), sprintf('La table %s doit exister après run().', $table));
|
||||
}
|
||||
}
|
||||
|
||||
private function countMigrations(): int
|
||||
{
|
||||
return (int) $this->db->query('SELECT COUNT(*) FROM migrations')->fetchColumn();
|
||||
}
|
||||
}
|
||||
87
tests/Kernel/ModuleRegistryTest.php
Normal file
87
tests/Kernel/ModuleRegistryTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleInterface;
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleRegistry;
|
||||
use Netig\Netslim\Kernel\Runtime\RuntimePaths;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ModuleRegistryTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
RuntimePaths::resetApplicationRoot();
|
||||
RuntimePaths::resetProjectRoot();
|
||||
ModuleRegistry::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RuntimePaths::resetApplicationRoot();
|
||||
RuntimePaths::resetProjectRoot();
|
||||
ModuleRegistry::reset();
|
||||
}
|
||||
|
||||
public function testModulesAreDeclaredInExpectedOrderForFixtureApplication(): void
|
||||
{
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application');
|
||||
|
||||
$moduleClasses = array_map(
|
||||
static fn (ModuleInterface $module): string => $module::class,
|
||||
ModuleRegistry::modules(),
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'Netig\Netslim\Kernel\Runtime\KernelModule',
|
||||
'Netig\Netslim\Identity\IdentityModule',
|
||||
'Netig\Netslim\Settings\SettingsModule',
|
||||
'Netig\Netslim\AuditLog\AuditLogModule',
|
||||
'Netig\Netslim\Notifications\NotificationsModule',
|
||||
'Netig\Netslim\Taxonomy\TaxonomyModule',
|
||||
'Netig\Netslim\Media\MediaModule',
|
||||
], $moduleClasses);
|
||||
}
|
||||
|
||||
public function testModuleClassNamesExposeTheActiveApplicationComposition(): void
|
||||
{
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application');
|
||||
|
||||
self::assertSame([
|
||||
'Netig\Netslim\Kernel\Runtime\KernelModule',
|
||||
'Netig\Netslim\Identity\IdentityModule',
|
||||
'Netig\Netslim\Settings\SettingsModule',
|
||||
'Netig\Netslim\AuditLog\AuditLogModule',
|
||||
'Netig\Netslim\Notifications\NotificationsModule',
|
||||
'Netig\Netslim\Taxonomy\TaxonomyModule',
|
||||
'Netig\Netslim\Media\MediaModule',
|
||||
], ModuleRegistry::moduleClassNames());
|
||||
}
|
||||
|
||||
public function testApplicationManifestIsResolvedFromTheActiveApplicationRoot(): void
|
||||
{
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application');
|
||||
|
||||
self::assertFileExists(RuntimePaths::getApplicationConfigPath('modules.php'));
|
||||
}
|
||||
|
||||
public function testEveryModuleExposesResolvableMetadata(): void
|
||||
{
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application');
|
||||
|
||||
foreach (ModuleRegistry::modules() as $module) {
|
||||
self::assertNotSame([], $module->definitions(), $module::class . ' should expose DI definitions.');
|
||||
|
||||
foreach ($module->templateNamespaces() as $namespace => $templatePath) {
|
||||
self::assertNotSame('', $namespace, $module::class . ' should declare a non-empty Twig namespace.');
|
||||
self::assertDirectoryExists($templatePath, $module::class . ' should point Twig to an existing template directory.');
|
||||
}
|
||||
|
||||
foreach ($module->twigExtensions() as $twigExtensionClass) {
|
||||
self::assertTrue(class_exists($twigExtensionClass), $module::class . ' should reference an existing Twig extension class.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
tests/Kernel/ModuleSchemaTest.php
Normal file
50
tests/Kernel/ModuleSchemaTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleRegistry;
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ProvidesSchemaInterface;
|
||||
use Netig\Netslim\Kernel\Runtime\RuntimePaths;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ModuleSchemaTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application');
|
||||
ModuleRegistry::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RuntimePaths::resetApplicationRoot();
|
||||
RuntimePaths::resetProjectRoot();
|
||||
ModuleRegistry::reset();
|
||||
}
|
||||
|
||||
public function testFeatureModulesDeclareOwnedMigrationDirectoriesAndTables(): void
|
||||
{
|
||||
$schemaModules = array_values(array_filter(
|
||||
ModuleRegistry::modules(),
|
||||
static fn (object $module): bool => $module instanceof ProvidesSchemaInterface,
|
||||
));
|
||||
|
||||
self::assertCount(6, $schemaModules);
|
||||
|
||||
foreach ($schemaModules as $module) {
|
||||
foreach ($module->migrationDirectories() as $directory) {
|
||||
self::assertDirectoryExists($directory, $module::class . ' should expose an existing migration directory.');
|
||||
self::assertNotSame([], glob(rtrim($directory, '/') . '/*.php') ?: [], $module::class . ' should expose at least one migration file.');
|
||||
}
|
||||
|
||||
self::assertNotSame([], $module->requiredTables(), $module::class . ' should expose owned tables for readiness checks.');
|
||||
}
|
||||
}
|
||||
|
||||
public function testLegacyGlobalMigrationsDirectoryIsNoLongerUsed(): void
|
||||
{
|
||||
self::assertSame([], glob(dirname(__DIR__, 2) . '/database/migrations/*.php') ?: []);
|
||||
}
|
||||
}
|
||||
20
tests/Kernel/NotFoundExceptionTest.php
Normal file
20
tests/Kernel/NotFoundExceptionTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class NotFoundExceptionTest extends TestCase
|
||||
{
|
||||
public function testMessageContainsEntityAndIdentifier(): void
|
||||
{
|
||||
$exception = new NotFoundException('Ressource', 'mon-slug');
|
||||
|
||||
self::assertSame('Ressource introuvable : mon-slug', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
79
tests/Kernel/ProvisionerTest.php
Normal file
79
tests/Kernel/ProvisionerTest.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\Provisioner;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class ProvisionerTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
private string $lockPath;
|
||||
|
||||
private array $envBackup = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
|
||||
|
||||
$this->lockPath = dirname(__DIR__, 2) . '/database/.provision.lock';
|
||||
@unlink($this->lockPath);
|
||||
|
||||
$this->envBackup = [
|
||||
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? null,
|
||||
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? null,
|
||||
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? null,
|
||||
];
|
||||
|
||||
$_ENV['ADMIN_USERNAME'] = 'Admin';
|
||||
$_ENV['ADMIN_EMAIL'] = 'Admin@example.com';
|
||||
$_ENV['ADMIN_PASSWORD'] = 'secret123456';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->lockPath);
|
||||
|
||||
foreach ($this->envBackup as $key => $value) {
|
||||
if ($value === null) {
|
||||
unset($_ENV[$key]);
|
||||
} else {
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRunCreatesProvisionLockAndSeedsAdminUser(): void
|
||||
{
|
||||
Provisioner::run($this->db);
|
||||
|
||||
self::assertFileExists($this->lockPath);
|
||||
|
||||
$row = $this->db->query('SELECT username, email, role FROM users')->fetch();
|
||||
|
||||
self::assertIsArray($row);
|
||||
self::assertSame('admin', $row['username']);
|
||||
self::assertSame('admin@example.com', $row['email']);
|
||||
self::assertSame('admin', $row['role']);
|
||||
}
|
||||
|
||||
public function testRunIsIdempotent(): void
|
||||
{
|
||||
Provisioner::run($this->db);
|
||||
Provisioner::run($this->db);
|
||||
|
||||
$count = (int) $this->db->query('SELECT COUNT(*) FROM users WHERE username = "admin"')->fetchColumn();
|
||||
|
||||
self::assertSame(1, $count);
|
||||
}
|
||||
}
|
||||
55
tests/Kernel/RequestContextTest.php
Normal file
55
tests/Kernel/RequestContextTest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Request\RequestContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class RequestContextTest extends TestCase
|
||||
{
|
||||
public function testIsHttpsReturnsTrueWhenNativeHttpsFlagIsEnabled(): void
|
||||
{
|
||||
self::assertTrue(RequestContext::isHttps([
|
||||
'HTTPS' => 'on',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testIsHttpsReturnsTrueWhenTrustedProxyForwardsHttps(): void
|
||||
{
|
||||
self::assertTrue(RequestContext::isHttps([
|
||||
'REMOTE_ADDR' => '127.0.0.1',
|
||||
'HTTP_X_FORWARDED_PROTO' => 'https, http',
|
||||
], ['127.0.0.1']));
|
||||
}
|
||||
|
||||
public function testIsHttpsIgnoresForwardedProtoWhenProxyIsNotTrusted(): void
|
||||
{
|
||||
self::assertFalse(RequestContext::isHttps([
|
||||
'REMOTE_ADDR' => '10.0.0.5',
|
||||
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||
], ['127.0.0.1']));
|
||||
}
|
||||
|
||||
public function testTrustedProxiesFromEnvironmentTrimsValues(): void
|
||||
{
|
||||
self::assertSame(['127.0.0.1', '::1'], RequestContext::trustedProxiesFromEnvironment([
|
||||
'TRUSTED_PROXIES' => ' 127.0.0.1 , ::1 ',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testTrustedProxiesFromEnvironmentFallsBackToProcessEnvWhenDotenvValueIsBlank(): void
|
||||
{
|
||||
putenv('TRUSTED_PROXIES=*');
|
||||
|
||||
try {
|
||||
self::assertSame(['*'], RequestContext::trustedProxiesFromEnvironment([
|
||||
'TRUSTED_PROXIES' => '',
|
||||
]));
|
||||
} finally {
|
||||
putenv('TRUSTED_PROXIES');
|
||||
}
|
||||
}
|
||||
}
|
||||
73
tests/Kernel/RoutesTest.php
Normal file
73
tests/Kernel/RoutesTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleRegistry;
|
||||
use Netig\Netslim\Kernel\Runtime\Routing\Routes;
|
||||
use Netig\Netslim\Kernel\Runtime\RuntimePaths;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Factory\AppFactory;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class RoutesTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application');
|
||||
ModuleRegistry::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RuntimePaths::resetApplicationRoot();
|
||||
RuntimePaths::resetProjectRoot();
|
||||
ModuleRegistry::reset();
|
||||
}
|
||||
|
||||
public function testRegisterDeclaresOnlyCoreModuleRoutesForFixtureApplication(): void
|
||||
{
|
||||
$app = AppFactory::create();
|
||||
Routes::register($app);
|
||||
|
||||
$actual = [];
|
||||
|
||||
foreach ($app->getRouteCollector()->getRoutes() as $route) {
|
||||
$pattern = $route->getPattern();
|
||||
$methods = array_values(array_diff($route->getMethods(), ['HEAD', 'OPTIONS']));
|
||||
|
||||
$actual[$pattern] ??= [];
|
||||
$actual[$pattern] = array_values(array_unique(array_merge($actual[$pattern], $methods)));
|
||||
sort($actual[$pattern]);
|
||||
}
|
||||
|
||||
ksort($actual);
|
||||
|
||||
$expected = [
|
||||
'/account/password' => ['GET', 'POST'],
|
||||
'/admin/categories' => ['GET'],
|
||||
'/admin/categories/create' => ['POST'],
|
||||
'/admin/categories/delete/{id}' => ['POST'],
|
||||
'/admin/media' => ['GET'],
|
||||
'/admin/media/delete/{id}' => ['POST'],
|
||||
'/admin/media/picker' => ['GET'],
|
||||
'/admin/media/upload' => ['POST'],
|
||||
'/admin/users' => ['GET'],
|
||||
'/admin/users/create' => ['GET', 'POST'],
|
||||
'/admin/users/delete/{id}' => ['POST'],
|
||||
'/admin/users/role/{id}' => ['POST'],
|
||||
'/auth/login' => ['GET', 'POST'],
|
||||
'/auth/logout' => ['POST'],
|
||||
'/password/forgot' => ['GET', 'POST'],
|
||||
'/password/reset' => ['GET', 'POST'],
|
||||
];
|
||||
|
||||
foreach ($expected as $pattern => $methods) {
|
||||
sort($methods);
|
||||
}
|
||||
ksort($expected);
|
||||
|
||||
self::assertSame($expected, $actual);
|
||||
}
|
||||
}
|
||||
87
tests/Kernel/RuntimePathsTest.php
Normal file
87
tests/Kernel/RuntimePathsTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\RuntimePaths;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class RuntimePathsTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
RuntimePaths::resetApplicationRoot();
|
||||
RuntimePaths::resetProjectRoot();
|
||||
}
|
||||
|
||||
public function testGetProjectRootReturnsRepositoryRoot(): void
|
||||
{
|
||||
self::assertSame(dirname(__DIR__, 2), RuntimePaths::getProjectRoot());
|
||||
}
|
||||
|
||||
public function testGetConfigPathReturnsRootConfigDirectoryAndFilePath(): void
|
||||
{
|
||||
self::assertSame(dirname(__DIR__, 2) . '/config', RuntimePaths::getConfigPath());
|
||||
self::assertSame(dirname(__DIR__, 2) . '/config/modules.php', RuntimePaths::getConfigPath('modules.php'));
|
||||
}
|
||||
|
||||
public function testApplicationRootDefaultsToProjectRoot(): void
|
||||
{
|
||||
self::assertSame(dirname(__DIR__, 2), RuntimePaths::getApplicationRoot());
|
||||
self::assertSame(dirname(__DIR__, 2) . '/config', RuntimePaths::getApplicationConfigPath());
|
||||
}
|
||||
|
||||
public function testApplicationRootCanPointToFixtureApplication(): void
|
||||
{
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__, 2) . '/tests/Fixtures/Application');
|
||||
|
||||
self::assertSame(dirname(__DIR__, 2) . '/tests/Fixtures/Application', RuntimePaths::getApplicationRoot());
|
||||
self::assertSame(dirname(__DIR__, 2) . '/tests/Fixtures/Application/config/modules.php', RuntimePaths::getApplicationConfigPath('modules.php'));
|
||||
self::assertSame(dirname(__DIR__, 2) . '/tests/Fixtures/Application/templates/Kernel', RuntimePaths::getApplicationPath('templates/Kernel'));
|
||||
}
|
||||
|
||||
public function testGetTwigCacheReturnsFalseInDev(): void
|
||||
{
|
||||
self::assertFalse(RuntimePaths::getTwigCache(true));
|
||||
}
|
||||
|
||||
public function testGetTwigCacheReturnsCachePathOutsideDev(): void
|
||||
{
|
||||
$cachePath = RuntimePaths::getTwigCache(false);
|
||||
|
||||
self::assertIsString($cachePath);
|
||||
self::assertStringEndsWith('/var/cache/twig', $cachePath);
|
||||
}
|
||||
|
||||
public function testGetDatabasePathCreatesDatabaseFileWhenMissing(): void
|
||||
{
|
||||
$dbFile = dirname(__DIR__, 2) . '/database/app.sqlite';
|
||||
$dbDir = dirname($dbFile);
|
||||
$backup = $dbFile . '.bak-test';
|
||||
|
||||
if (file_exists($backup)) {
|
||||
@unlink($backup);
|
||||
}
|
||||
|
||||
if (file_exists($dbFile)) {
|
||||
rename($dbFile, $backup);
|
||||
}
|
||||
|
||||
@unlink($dbFile);
|
||||
|
||||
try {
|
||||
$path = RuntimePaths::getDatabasePath();
|
||||
|
||||
self::assertSame($dbFile, $path);
|
||||
self::assertDirectoryExists($dbDir);
|
||||
self::assertFileExists($dbFile);
|
||||
} finally {
|
||||
@unlink($dbFile);
|
||||
if (file_exists($backup)) {
|
||||
rename($backup, $dbFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
tests/Kernel/SessionManagerCoverageTest.php
Normal file
33
tests/Kernel/SessionManagerCoverageTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Session\SessionManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class SessionManagerCoverageTest extends TestCase
|
||||
{
|
||||
private SessionManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
$this->manager = new SessionManager();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
public function testGetUserIdCastsNumericStringToInteger(): void
|
||||
{
|
||||
$_SESSION['user_id'] = '42';
|
||||
|
||||
self::assertSame(42, $this->manager->getUserId());
|
||||
self::assertTrue($this->manager->isAuthenticated());
|
||||
}
|
||||
}
|
||||
43
tests/Kernel/SessionManagerEdgeCasesTest.php
Normal file
43
tests/Kernel/SessionManagerEdgeCasesTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Session\SessionManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class SessionManagerEdgeCasesTest extends TestCase
|
||||
{
|
||||
private SessionManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
$this->manager = new SessionManager();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
public function testGetUserIdReturnsNullForEmptyString(): void
|
||||
{
|
||||
$_SESSION['user_id'] = '';
|
||||
|
||||
self::assertNull($this->manager->getUserId());
|
||||
self::assertFalse($this->manager->isAuthenticated());
|
||||
}
|
||||
|
||||
public function testSetUserUsesDefaultRoleUser(): void
|
||||
{
|
||||
$this->manager->setUser(12, 'julien');
|
||||
|
||||
self::assertSame('user', $_SESSION['role']);
|
||||
self::assertFalse($this->manager->isAdmin());
|
||||
self::assertFalse($this->manager->isEditor());
|
||||
}
|
||||
}
|
||||
168
tests/Kernel/SessionManagerTest.php
Normal file
168
tests/Kernel/SessionManagerTest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Session\SessionManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour SessionManager.
|
||||
*
|
||||
* Vérifie la lecture et l'écriture des données d'authentification
|
||||
* dans $_SESSION, ainsi que la destruction de session.
|
||||
*
|
||||
* Note : session_start() n'est pas appelé dans ces tests — SessionManager
|
||||
* manipule directement $_SESSION, ce qui fonctionne en CLI sans session active.
|
||||
* session_regenerate_id() et session_destroy() sont gardés par un test
|
||||
* session_status() === PHP_SESSION_ACTIVE dans SessionManager, ce qui les rend
|
||||
* sans effet en contexte CLI et évite toute notice PHP.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class SessionManagerTest extends TestCase
|
||||
{
|
||||
private SessionManager $manager;
|
||||
|
||||
/**
|
||||
* Réinitialise $_SESSION avant chaque test pour garantir l'isolation.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
$this->manager = new SessionManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialise $_SESSION après chaque test.
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
|
||||
// ── isAuthenticated ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sans session active, isAuthenticated() doit retourner false.
|
||||
*/
|
||||
public function testIsAuthenticatedWithoutSession(): void
|
||||
{
|
||||
$this->assertFalse($this->manager->isAuthenticated());
|
||||
}
|
||||
|
||||
/**
|
||||
* Après setUser(), isAuthenticated() doit retourner true.
|
||||
*/
|
||||
public function testIsAuthenticatedAfterSetUser(): void
|
||||
{
|
||||
$this->manager->setUser(1, 'alice', 'user');
|
||||
|
||||
$this->assertTrue($this->manager->isAuthenticated());
|
||||
}
|
||||
|
||||
|
||||
// ── getUserId ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sans session active, getUserId() doit retourner null.
|
||||
*/
|
||||
public function testGetUserIdWithoutSession(): void
|
||||
{
|
||||
$this->assertNull($this->manager->getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Après setUser(), getUserId() doit retourner l'identifiant correct.
|
||||
*/
|
||||
public function testGetUserIdAfterSetUser(): void
|
||||
{
|
||||
$this->manager->setUser(42, 'alice', 'user');
|
||||
|
||||
$this->assertSame(42, $this->manager->getUserId());
|
||||
}
|
||||
|
||||
|
||||
// ── Rôles — isAdmin / isEditor ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Un utilisateur avec le rôle 'admin' doit être reconnu comme administrateur.
|
||||
*/
|
||||
public function testIsAdminWithAdminRole(): void
|
||||
{
|
||||
$this->manager->setUser(1, 'alice', 'admin');
|
||||
|
||||
$this->assertTrue($this->manager->isAdmin());
|
||||
$this->assertFalse($this->manager->isEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur avec le rôle 'editor' doit être reconnu comme éditeur.
|
||||
*/
|
||||
public function testIsEditorWithEditorRole(): void
|
||||
{
|
||||
$this->manager->setUser(1, 'alice', 'editor');
|
||||
|
||||
$this->assertFalse($this->manager->isAdmin());
|
||||
$this->assertTrue($this->manager->isEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur avec le rôle 'user' ne doit être ni admin ni éditeur.
|
||||
*/
|
||||
public function testUserRoleIsNeitherAdminNorEditor(): void
|
||||
{
|
||||
$this->manager->setUser(1, 'alice', 'user');
|
||||
|
||||
$this->assertFalse($this->manager->isAdmin());
|
||||
$this->assertFalse($this->manager->isEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sans session active, isAdmin() doit retourner false.
|
||||
*/
|
||||
public function testIsAdminWithoutSession(): void
|
||||
{
|
||||
$this->assertFalse($this->manager->isAdmin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sans session active, isEditor() doit retourner false.
|
||||
*/
|
||||
public function testIsEditorWithoutSession(): void
|
||||
{
|
||||
$this->assertFalse($this->manager->isEditor());
|
||||
}
|
||||
|
||||
|
||||
// ── Données en session ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* setUser() doit écrire le username et le rôle dans $_SESSION.
|
||||
*/
|
||||
public function testSetUserWritesToSession(): void
|
||||
{
|
||||
$this->manager->setUser(5, 'bob', 'editor');
|
||||
|
||||
$this->assertSame(5, $_SESSION['user_id']);
|
||||
$this->assertSame('bob', $_SESSION['username']);
|
||||
$this->assertSame('editor', $_SESSION['role']);
|
||||
}
|
||||
|
||||
|
||||
// ── destroy ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Après destroy(), isAuthenticated() doit retourner false.
|
||||
*/
|
||||
public function testDestroyClearsSession(): void
|
||||
{
|
||||
$this->manager->setUser(1, 'alice', 'user');
|
||||
$this->manager->destroy();
|
||||
|
||||
$this->assertFalse($this->manager->isAuthenticated());
|
||||
$this->assertNull($this->manager->getUserId());
|
||||
$this->assertEmpty($_SESSION);
|
||||
}
|
||||
}
|
||||
122
tests/Kernel/SlugHelperTest.php
Normal file
122
tests/Kernel/SlugHelperTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Kernel;
|
||||
|
||||
use Netig\Netslim\Kernel\Support\Util\SlugHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour SlugHelper::generate().
|
||||
*
|
||||
* Couvre la translittération ASCII, la normalisation en minuscules,
|
||||
* le remplacement des caractères non alphanumériques, et les cas limites.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class SlugHelperTest extends TestCase
|
||||
{
|
||||
// ── Cas nominaux ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Une chaîne ASCII simple doit être mise en minuscules.
|
||||
*/
|
||||
public function testSimpleAsciiString(): void
|
||||
{
|
||||
$this->assertSame('hello-world', SlugHelper::generate('Hello World'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les caractères accentués doivent être translittérés en ASCII.
|
||||
*/
|
||||
public function testAccentedCharacters(): void
|
||||
{
|
||||
$this->assertSame('ete-en-foret', SlugHelper::generate('Été en forêt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* La cédille et les caractères spéciaux courants doivent être translittérés.
|
||||
*/
|
||||
public function testCedillaAndSpecialCharacters(): void
|
||||
{
|
||||
$this->assertSame('ca-la', SlugHelper::generate('Ça & Là !'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les tirets multiples consécutifs doivent être fusionnés en un seul.
|
||||
*/
|
||||
public function testMultipleConsecutiveHyphens(): void
|
||||
{
|
||||
$this->assertSame('foo-bar', SlugHelper::generate('foo bar'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les tirets en début et fin de slug doivent être supprimés.
|
||||
*/
|
||||
public function testLeadingAndTrailingHyphen(): void
|
||||
{
|
||||
$this->assertSame('foo', SlugHelper::generate(' foo '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les chiffres doivent être conservés dans le slug.
|
||||
*/
|
||||
public function testDigitsPreserved(): void
|
||||
{
|
||||
$this->assertSame('contenu-2024', SlugHelper::generate('Contenu 2024'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Les tirets déjà présents dans la chaîne doivent être conservés (fusionnés si doublons).
|
||||
*/
|
||||
public function testHyphensInSourceString(): void
|
||||
{
|
||||
$this->assertSame('mon-contenu', SlugHelper::generate('mon-contenu'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une chaîne entièrement en majuscules doit être passée en minuscules.
|
||||
*/
|
||||
public function testUppercaseString(): void
|
||||
{
|
||||
$this->assertSame('php-est-super', SlugHelper::generate('PHP EST SUPER'));
|
||||
}
|
||||
|
||||
|
||||
// ── Cas limites ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Une chaîne vide doit retourner une chaîne vide.
|
||||
*/
|
||||
public function testEmptyString(): void
|
||||
{
|
||||
$this->assertSame('', SlugHelper::generate(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une chaîne composée uniquement d'espaces doit retourner une chaîne vide.
|
||||
*/
|
||||
public function testSpacesOnlyString(): void
|
||||
{
|
||||
$this->assertSame('', SlugHelper::generate(' '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Une chaîne composée uniquement de caractères spéciaux sans équivalent ASCII
|
||||
* doit retourner une chaîne vide.
|
||||
*/
|
||||
public function testCharactersWithoutAsciiEquivalent(): void
|
||||
{
|
||||
// Les caractères CJK n'ont pas d'équivalent ASCII//TRANSLIT
|
||||
$result = SlugHelper::generate('日本語');
|
||||
$this->assertSame('', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un slug déjà valide doit rester identique.
|
||||
*/
|
||||
public function testAlreadyValidSlug(): void
|
||||
{
|
||||
$this->assertSame('mon-slug-valide', SlugHelper::generate('mon-slug-valide'));
|
||||
}
|
||||
}
|
||||
401
tests/Media/MediaControllerTest.php
Normal file
401
tests/Media/MediaControllerTest.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
|
||||
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
|
||||
use Netig\Netslim\Media\Application\MediaServiceInterface;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReference;
|
||||
use Netig\Netslim\Media\Domain\Entity\Media;
|
||||
use Netig\Netslim\Media\Domain\Exception\FileTooLargeException;
|
||||
use Netig\Netslim\Media\Domain\Exception\InvalidMimeTypeException;
|
||||
use Netig\Netslim\Media\Domain\Exception\StorageException;
|
||||
use Netig\Netslim\Media\UI\Http\MediaController;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour MediaController.
|
||||
*
|
||||
* Couvre index(), upload() et delete() :
|
||||
* - index : filtrage admin vs utilisateur ordinaire
|
||||
* - upload : absence de fichier, erreur PSR-7, exceptions métier (taille, MIME, stockage), succès
|
||||
* - delete : introuvable, non-propriétaire, succès propriétaire, succès admin
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
/** @var MediaServiceInterface&MockObject */
|
||||
private MediaServiceInterface $mediaService;
|
||||
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
/** @var SessionManagerInterface&MockObject */
|
||||
private SessionManagerInterface $sessionManager;
|
||||
|
||||
private MediaController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->mediaService = $this->createMock(MediaServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
|
||||
$this->controller = new MediaController(
|
||||
$this->view,
|
||||
$this->mediaService,
|
||||
$this->flash,
|
||||
$this->sessionManager,
|
||||
);
|
||||
}
|
||||
|
||||
// ── index ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* index() doit appeler findAll() pour un admin et rendre la vue.
|
||||
*/
|
||||
public function testIndexShowsAllMediaForAdmin(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
|
||||
$this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findByUserIdPaginated');
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Media/admin/index.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* index() doit appeler findAll() pour un éditeur.
|
||||
*/
|
||||
public function testIndexShowsAllMediaForEditor(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(true);
|
||||
|
||||
$this->mediaService->expects($this->once())->method('findPaginated')->with(1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findByUserIdPaginated');
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Media/admin/index.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* index() doit appeler findByUserId() pour un utilisateur ordinaire.
|
||||
*/
|
||||
public function testIndexShowsOwnMediaForRegularUser(): void
|
||||
{
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(42);
|
||||
|
||||
$this->mediaService->expects($this->once())->method('findByUserIdPaginated')->with(42, 1, 12)->willReturn(new PaginatedResult([], 0, 1, 12));
|
||||
$this->mediaService->expects($this->never())->method('findPaginated');
|
||||
|
||||
$this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* index() doit exposer les résumés d'usage typés par identifiant de média.
|
||||
*/
|
||||
public function testIndexPassesMediaUsageSummaryToView(): void
|
||||
{
|
||||
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42);
|
||||
$usageSummary = [
|
||||
'count' => 1,
|
||||
'references' => [new MediaUsageReference(8, 'Contenu lié', '/admin/content/edit/8')],
|
||||
];
|
||||
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->mediaService->expects($this->once())
|
||||
->method('findPaginated')
|
||||
->with(1, 12)
|
||||
->willReturn(new PaginatedResult([$media], 1, 1, 12));
|
||||
$this->mediaService->expects($this->once())
|
||||
->method('getUsageSummaries')
|
||||
->with([$media], 5)
|
||||
->willReturn([$media->getId() => $usageSummary]);
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with(
|
||||
$this->anything(),
|
||||
'@Media/admin/index.twig',
|
||||
$this->callback(static function (array $data) use ($media, $usageSummary): bool {
|
||||
return isset($data['mediaUsage'][$media->getId()])
|
||||
&& $data['mediaUsage'][$media->getId()] === $usageSummary;
|
||||
}),
|
||||
)
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->index($this->makeGet('/admin/media'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── upload ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* upload() doit retourner 400 JSON si aucun fichier n'est dans la requête.
|
||||
*/
|
||||
public function testUploadReturns400WhenNoFilePresent(): void
|
||||
{
|
||||
$req = $this->makePost('/admin/media/upload');
|
||||
$res = $this->controller->upload($req, $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 400);
|
||||
$this->assertJsonContentType($res);
|
||||
$this->assertJsonContains($res, ['error' => "Aucun fichier reçu ou erreur d'upload"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* upload() doit retourner 400 JSON si le fichier PSR-7 signale une erreur d'upload.
|
||||
*/
|
||||
public function testUploadReturns400WhenFileHasUploadError(): void
|
||||
{
|
||||
/** @var UploadedFileInterface&MockObject $file */
|
||||
$file = $this->createMock(UploadedFileInterface::class);
|
||||
$file->method('getError')->willReturn(UPLOAD_ERR_INI_SIZE);
|
||||
|
||||
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
|
||||
$res = $this->controller->upload($req, $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 400);
|
||||
$this->assertJsonContains($res, ['error' => "Aucun fichier reçu ou erreur d'upload"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* upload() doit retourner 413 JSON si le fichier dépasse la taille autorisée.
|
||||
*/
|
||||
public function testUploadReturns413OnFileTooLarge(): void
|
||||
{
|
||||
$file = $this->makeValidUploadedFile();
|
||||
$this->mediaService->method('store')
|
||||
->willThrowException(new FileTooLargeException(2 * 1024 * 1024));
|
||||
|
||||
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
|
||||
$res = $this->controller->upload($req, $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 413);
|
||||
$this->assertJsonContentType($res);
|
||||
}
|
||||
|
||||
/**
|
||||
* upload() doit retourner 415 JSON si le type MIME n'est pas autorisé.
|
||||
*/
|
||||
public function testUploadReturns415OnInvalidMimeType(): void
|
||||
{
|
||||
$file = $this->makeValidUploadedFile();
|
||||
$this->mediaService->method('store')
|
||||
->willThrowException(new InvalidMimeTypeException('application/pdf'));
|
||||
|
||||
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
|
||||
$res = $this->controller->upload($req, $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 415);
|
||||
$this->assertJsonContentType($res);
|
||||
}
|
||||
|
||||
/**
|
||||
* upload() doit retourner 500 JSON si une erreur de stockage survient.
|
||||
*/
|
||||
public function testUploadReturns500OnStorageException(): void
|
||||
{
|
||||
$file = $this->makeValidUploadedFile();
|
||||
$this->mediaService->method('store')
|
||||
->willThrowException(new StorageException('Disk full'));
|
||||
|
||||
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
|
||||
$res = $this->controller->upload($req, $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 500);
|
||||
$this->assertJsonContentType($res);
|
||||
}
|
||||
|
||||
/**
|
||||
* upload() doit retourner 200 JSON avec l'URL du fichier en cas de succès.
|
||||
*/
|
||||
public function testUploadReturns200JsonWithUrlOnSuccess(): void
|
||||
{
|
||||
$file = $this->makeValidUploadedFile();
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
$this->mediaService->method('store')->willReturn(new Media(12, 'abc123.webp', '/media/abc123.webp', 'hash', 1));
|
||||
|
||||
$req = $this->makePost('/admin/media/upload')->withUploadedFiles(['image' => $file]);
|
||||
$res = $this->controller->upload($req, $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
$this->assertJsonContentType($res);
|
||||
$this->assertJsonContains($res, ['success' => true, 'file' => '/media/abc123.webp', 'mediaId' => 12]);
|
||||
}
|
||||
|
||||
// ── delete ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() doit flasher une erreur et rediriger si le média est introuvable.
|
||||
*/
|
||||
public function testDeleteRedirectsWithErrorWhenMediaNotFound(): void
|
||||
{
|
||||
$this->mediaService->method('findById')->willReturn(null);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('media_error', 'Fichier introuvable');
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/media/delete/99'),
|
||||
$this->makeResponse(),
|
||||
['id' => '99'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/media');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit flasher une erreur si l'utilisateur n'est pas propriétaire du média.
|
||||
*/
|
||||
public function testDeleteRedirectsWithErrorWhenNotOwner(): void
|
||||
{
|
||||
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 10);
|
||||
$this->mediaService->method('findById')->willReturn($media);
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(99); // autre utilisateur
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('media_error', $this->stringContains('autorisé'));
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/media/delete/5'),
|
||||
$this->makeResponse(),
|
||||
['id' => '5'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/media');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit supprimer le média et rediriger avec succès si l'utilisateur est propriétaire.
|
||||
*/
|
||||
public function testDeleteSucceedsForOwner(): void
|
||||
{
|
||||
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42);
|
||||
$this->mediaService->method('findById')->willReturn($media);
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(42);
|
||||
|
||||
$this->mediaService->expects($this->once())->method('delete')->with($media);
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('media_success', 'Fichier supprimé');
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/media/delete/5'),
|
||||
$this->makeResponse(),
|
||||
['id' => '5'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/media');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit permettre la suppression à un admin même s'il n'est pas propriétaire.
|
||||
*/
|
||||
public function testDeleteSucceedsForAdmin(): void
|
||||
{
|
||||
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 10);
|
||||
$this->mediaService->method('findById')->willReturn($media);
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1); // admin, pas propriétaire
|
||||
|
||||
$this->mediaService->expects($this->once())->method('delete');
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/media/delete/5'),
|
||||
$this->makeResponse(),
|
||||
['id' => '5'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/media');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit bloquer la suppression si le média est encore référencé et lister les titres connus.
|
||||
*/
|
||||
public function testDeleteRedirectsWithDetailedErrorWhenMediaIsStillReferenced(): void
|
||||
{
|
||||
$media = new Media(5, 'file.webp', '/media/file.webp', 'abc', 42);
|
||||
$usageSummary = [
|
||||
'count' => 2,
|
||||
'references' => [
|
||||
new MediaUsageReference(8, 'Contenu A', '/admin/content/edit/8'),
|
||||
new MediaUsageReference(9, 'Contenu B', '/admin/content/edit/9'),
|
||||
],
|
||||
];
|
||||
|
||||
$this->mediaService->method('findById')->willReturn($media);
|
||||
$this->mediaService->expects($this->once())
|
||||
->method('getUsageSummary')
|
||||
->with($media, 3)
|
||||
->willReturn($usageSummary);
|
||||
$this->mediaService->expects($this->never())->method('delete');
|
||||
$this->sessionManager->method('isAdmin')->willReturn(false);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(42);
|
||||
|
||||
$this->flash->expects($this->once())
|
||||
->method('set')
|
||||
->with(
|
||||
'media_error',
|
||||
$this->callback(static fn (string $message): bool => str_contains($message, '2 contenu(s)')
|
||||
&& str_contains($message, '« Contenu A »')
|
||||
&& str_contains($message, '« Contenu B »')),
|
||||
);
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/media/delete/5'),
|
||||
$this->makeResponse(),
|
||||
['id' => '5'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/media');
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée un mock d'UploadedFileInterface sans erreur d'upload.
|
||||
*
|
||||
* @return UploadedFileInterface&MockObject
|
||||
*/
|
||||
private function makeValidUploadedFile(): UploadedFileInterface
|
||||
{
|
||||
$file = $this->createMock(UploadedFileInterface::class);
|
||||
$file->method('getError')->willReturn(UPLOAD_ERR_OK);
|
||||
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
56
tests/Media/MediaModelTest.php
Normal file
56
tests/Media/MediaModelTest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use DateTime;
|
||||
use Netig\Netslim\Media\Domain\Entity\Media;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class MediaModelTest extends TestCase
|
||||
{
|
||||
public function testConstructAndGettersExposeMediaData(): void
|
||||
{
|
||||
$createdAt = new DateTime('2026-03-01 10:00:00');
|
||||
$media = new Media(8, 'file.webp', '/media/file.webp', 'abc123', 12, $createdAt);
|
||||
|
||||
self::assertSame(8, $media->getId());
|
||||
self::assertSame('file.webp', $media->getFilename());
|
||||
self::assertSame('/media/file.webp', $media->getUrl());
|
||||
self::assertSame('abc123', $media->getHash());
|
||||
self::assertSame(12, $media->getUserId());
|
||||
self::assertSame($createdAt, $media->getCreatedAt());
|
||||
}
|
||||
|
||||
public function testFromArrayHydratesMedia(): void
|
||||
{
|
||||
$media = Media::fromArray([
|
||||
'id' => '9',
|
||||
'filename' => 'stored.webp',
|
||||
'url' => '/media/stored.webp',
|
||||
'hash' => 'hash-9',
|
||||
'user_id' => '3',
|
||||
'created_at' => '2026-03-02 12:30:00',
|
||||
]);
|
||||
|
||||
self::assertSame(9, $media->getId());
|
||||
self::assertSame('stored.webp', $media->getFilename());
|
||||
self::assertSame('/media/stored.webp', $media->getUrl());
|
||||
self::assertSame('hash-9', $media->getHash());
|
||||
self::assertSame(3, $media->getUserId());
|
||||
self::assertSame('2026-03-02 12:30:00', $media->getCreatedAt()->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function testCreatedAtDefaultsToNowWhenMissing(): void
|
||||
{
|
||||
$before = new DateTime('-2 seconds');
|
||||
$media = new Media(1, 'f.webp', '/media/f.webp', 'h', null);
|
||||
$after = new DateTime('+2 seconds');
|
||||
|
||||
self::assertGreaterThanOrEqual($before->getTimestamp(), $media->getCreatedAt()->getTimestamp());
|
||||
self::assertLessThanOrEqual($after->getTimestamp(), $media->getCreatedAt()->getTimestamp());
|
||||
}
|
||||
}
|
||||
335
tests/Media/MediaRepositoryTest.php
Normal file
335
tests/Media/MediaRepositoryTest.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Domain\Entity\Media;
|
||||
use Netig\Netslim\Media\Infrastructure\PdoMediaRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PdoMediaRepository.
|
||||
*
|
||||
* Vérifie que chaque méthode du dépôt construit le bon SQL,
|
||||
* lie les bons paramètres et retourne les bonnes valeurs.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PdoMediaRepository $repository;
|
||||
|
||||
/**
|
||||
* Données représentant une ligne média en base de données.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $rowImage;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->repository = new PdoMediaRepository($this->db);
|
||||
|
||||
$this->rowImage = [
|
||||
'id' => 1,
|
||||
'filename' => 'photo.webp',
|
||||
'url' => '/media/photo.webp',
|
||||
'hash' => str_repeat('a', 64),
|
||||
'user_id' => 2,
|
||||
'created_at' => '2024-06-01 10:00:00',
|
||||
];
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
private function stmtForRead(array $rows = [], array|false $row = false): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetchAll')->willReturn($rows);
|
||||
$stmt->method('fetch')->willReturn($row);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
private function stmtForWrite(int $rowCount = 1): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('rowCount')->willReturn($rowCount);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── findAll ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findAll() retourne un tableau vide si aucun média n'existe.
|
||||
*/
|
||||
public function testFindAllReturnsEmptyArrayWhenNone(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
$this->db->method('query')->willReturn($stmt);
|
||||
|
||||
$this->assertSame([], $this->repository->findAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* findAll() retourne des instances Media hydratées.
|
||||
*/
|
||||
public function testFindAllReturnsMediaInstances(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([$this->rowImage]);
|
||||
$this->db->method('query')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findAll();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertInstanceOf(Media::class, $result[0]);
|
||||
$this->assertSame('photo.webp', $result[0]->getFilename());
|
||||
$this->assertSame('/media/photo.webp', $result[0]->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* findAll() interroge bien la table `media`.
|
||||
*/
|
||||
public function testFindAllRequestsMediaQuery(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('query')
|
||||
->with($this->stringContains('FROM media'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->repository->findAll();
|
||||
}
|
||||
|
||||
|
||||
// ── findByUserId ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findByUserId() retourne un tableau vide si l'utilisateur n'a aucun média.
|
||||
*/
|
||||
public function testFindByUserIdReturnsEmptyArrayWhenNone(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame([], $this->repository->findByUserId(99));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByUserId() retourne uniquement les médias de l'utilisateur donné.
|
||||
*/
|
||||
public function testFindByUserIdReturnsUserMedia(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([$this->rowImage]);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findByUserId(2);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame(2, $result[0]->getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* findByUserId() exécute avec le bon user_id.
|
||||
*/
|
||||
public function testFindByUserIdQueriesWithCorrectUserId(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(5, $params, true)));
|
||||
|
||||
$this->repository->findByUserId(5);
|
||||
}
|
||||
|
||||
|
||||
// ── findById ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findById() retourne null si le média est absent.
|
||||
*/
|
||||
public function testFindByIdReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findById(99));
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() retourne une instance Media si le média existe.
|
||||
*/
|
||||
public function testFindByIdReturnsMediaWhenFound(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: $this->rowImage);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findById(1);
|
||||
|
||||
$this->assertInstanceOf(Media::class, $result);
|
||||
$this->assertSame(1, $result->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() exécute avec le bon identifiant.
|
||||
*/
|
||||
public function testFindByIdQueriesWithCorrectId(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(8, $params, true)));
|
||||
|
||||
$this->repository->findById(8);
|
||||
}
|
||||
|
||||
|
||||
// ── findByHash ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findByHash() retourne null si aucun média ne correspond au hash.
|
||||
*/
|
||||
public function testFindByHashReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findByHash(str_repeat('b', 64)));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByHash() retourne une instance Media si le hash existe (doublon détecté).
|
||||
*/
|
||||
public function testFindByHashReturnsDuplicateMedia(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: $this->rowImage);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findByHash(str_repeat('a', 64));
|
||||
|
||||
$this->assertInstanceOf(Media::class, $result);
|
||||
$this->assertSame(str_repeat('a', 64), $result->getHash());
|
||||
}
|
||||
|
||||
/**
|
||||
* findByHash() exécute avec le bon hash.
|
||||
*/
|
||||
public function testFindByHashQueriesWithCorrectHash(): void
|
||||
{
|
||||
$hash = str_repeat('c', 64);
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array($hash, $params, true)));
|
||||
|
||||
$this->repository->findByHash($hash);
|
||||
}
|
||||
|
||||
|
||||
// ── create ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() prépare un INSERT avec les bonnes colonnes.
|
||||
*/
|
||||
public function testCreateCallsInsertWithCorrectData(): void
|
||||
{
|
||||
$media = Media::fromArray($this->rowImage);
|
||||
$stmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->expects($this->once())->method('prepare')
|
||||
->with($this->stringContains('INSERT INTO media'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(function (array $data) use ($media): bool {
|
||||
return $data[':filename'] === $media->getFilename()
|
||||
&& $data[':url'] === $media->getUrl()
|
||||
&& $data[':hash'] === $media->getHash()
|
||||
&& $data[':user_id'] === $media->getUserId()
|
||||
&& isset($data[':created_at']);
|
||||
}));
|
||||
|
||||
$this->db->method('lastInsertId')->willReturn('1');
|
||||
|
||||
$this->repository->create($media);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() retourne l'identifiant généré par la base de données.
|
||||
*/
|
||||
public function testCreateReturnsGeneratedId(): void
|
||||
{
|
||||
$media = Media::fromArray($this->rowImage);
|
||||
$stmt = $this->stmtForWrite();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
$this->db->method('lastInsertId')->willReturn('15');
|
||||
|
||||
$this->assertSame(15, $this->repository->create($media));
|
||||
}
|
||||
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() prépare un DELETE avec le bon identifiant.
|
||||
*/
|
||||
public function testDeleteCallsDeleteWithCorrectId(): void
|
||||
{
|
||||
$stmt = $this->stmtForWrite(1);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('DELETE FROM media'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(4, $params, true)));
|
||||
|
||||
$this->repository->delete(4);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() retourne le nombre de lignes supprimées.
|
||||
*/
|
||||
public function testDeleteReturnsDeletedRowCount(): void
|
||||
{
|
||||
$stmt = $this->stmtForWrite(1);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame(1, $this->repository->delete(4));
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() retourne 0 si le média n'existait plus.
|
||||
*/
|
||||
public function testDeleteReturnsZeroWhenNotFound(): void
|
||||
{
|
||||
$stmt = $this->stmtForWrite(0);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame(0, $this->repository->delete(99));
|
||||
}
|
||||
}
|
||||
80
tests/Media/MediaSchemaIntegrationTest.php
Normal file
80
tests/Media/MediaSchemaIntegrationTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\Migrator;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MediaSchemaIntegrationTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
|
||||
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
|
||||
Migrator::run($this->db);
|
||||
|
||||
$this->db->exec("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'alice', 'alice@example.com', 'hash', 'user')");
|
||||
$this->db->exec("INSERT INTO users (id, username, email, password_hash, role) VALUES (2, 'bob', 'bob@example.com', 'hash', 'user')");
|
||||
}
|
||||
|
||||
public function testMediaHashIsUniquePerUser(): void
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO media (filename, url, hash, user_id, created_at) VALUES (:filename, :url, :hash, :user_id, :created_at)',
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':filename' => 'first.webp',
|
||||
':url' => '/media/first.webp',
|
||||
':hash' => 'same-hash',
|
||||
':user_id' => 1,
|
||||
':created_at' => '2026-03-19 10:00:00',
|
||||
]);
|
||||
|
||||
$this->expectException(PDOException::class);
|
||||
|
||||
$stmt->execute([
|
||||
':filename' => 'second.webp',
|
||||
':url' => '/media/second.webp',
|
||||
':hash' => 'same-hash',
|
||||
':user_id' => 1,
|
||||
':created_at' => '2026-03-19 10:01:00',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testMediaHashCanBeSharedAcrossDifferentUsers(): void
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO media (filename, url, hash, user_id, created_at) VALUES (:filename, :url, :hash, :user_id, :created_at)',
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':filename' => 'alice.webp',
|
||||
':url' => '/media/alice.webp',
|
||||
':hash' => 'same-hash',
|
||||
':user_id' => 1,
|
||||
':created_at' => '2026-03-19 10:00:00',
|
||||
]);
|
||||
$stmt->execute([
|
||||
':filename' => 'bob.webp',
|
||||
':url' => '/media/bob.webp',
|
||||
':hash' => 'same-hash',
|
||||
':user_id' => 2,
|
||||
':created_at' => '2026-03-19 10:01:00',
|
||||
]);
|
||||
|
||||
$count = (int) $this->db->query("SELECT COUNT(*) FROM media WHERE hash = 'same-hash'")->fetchColumn();
|
||||
|
||||
self::assertSame(2, $count);
|
||||
}
|
||||
}
|
||||
103
tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php
Normal file
103
tests/Media/MediaServiceDuplicateAfterInsertRaceTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Application\MediaApplicationService;
|
||||
use Netig\Netslim\Media\Application\UseCase\DeleteMedia;
|
||||
use Netig\Netslim\Media\Application\UseCase\StoreMedia;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Media\Domain\Entity\Media;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaServiceDuplicateAfterInsertRaceTest extends TestCase
|
||||
{
|
||||
/** @var MediaRepositoryInterface&MockObject */
|
||||
private MediaRepositoryInterface $repository;
|
||||
|
||||
private MediaUsageReaderInterface $mediaUsageReader;
|
||||
|
||||
private string $uploadDir;
|
||||
|
||||
private MediaApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(MediaRepositoryInterface::class);
|
||||
$this->mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
$this->uploadDir = sys_get_temp_dir() . '/slim_media_race_' . uniqid('', true);
|
||||
@mkdir($this->uploadDir, 0755, true);
|
||||
|
||||
$storage = new LocalMediaStorage($this->uploadDir);
|
||||
|
||||
$this->service = new MediaApplicationService(
|
||||
$this->repository,
|
||||
$this->mediaUsageReader,
|
||||
new StoreMedia($this->repository, $storage, '/media', 5 * 1024 * 1024),
|
||||
new DeleteMedia($this->repository, $storage),
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->uploadDir . '/*') ?: [] as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
@rmdir($this->uploadDir);
|
||||
}
|
||||
|
||||
public function testReturnsDuplicateUrlWhenInsertRaceOccurs(): void
|
||||
{
|
||||
$tmpFile = $this->createMinimalGif();
|
||||
$hash = hash_file('sha256', $tmpFile);
|
||||
self::assertNotFalse($hash);
|
||||
|
||||
$duplicate = new Media(77, 'existing.gif', '/media/existing.gif', $hash, 1);
|
||||
|
||||
$this->repository->expects($this->exactly(2))
|
||||
->method('findByHashForUser')
|
||||
->with($hash, 1)
|
||||
->willReturnOnConsecutiveCalls(null, $duplicate);
|
||||
|
||||
$this->repository->expects($this->once())
|
||||
->method('create')
|
||||
->willThrowException(new PDOException('duplicate key'));
|
||||
|
||||
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
|
||||
$media = $this->service->store($file, 1);
|
||||
|
||||
self::assertSame('/media/existing.gif', $media->getUrl());
|
||||
self::assertSame(77, $media->getId());
|
||||
self::assertCount(0, glob($this->uploadDir . '/*') ?: []);
|
||||
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
private function makeUploadedFileFromPath(string $path, int $size): UploadedMediaInterface
|
||||
{
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn($size);
|
||||
$file->method('getTemporaryPath')->willReturn($path);
|
||||
$file->method('moveTo')->willReturnCallback(static function (string $dest) use ($path): void {
|
||||
copy($path, $dest);
|
||||
});
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
private function createMinimalGif(): string
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_gif_');
|
||||
self::assertNotFalse($tmpFile);
|
||||
file_put_contents($tmpFile, base64_decode('R0lGODdhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='));
|
||||
|
||||
return $tmpFile;
|
||||
}
|
||||
}
|
||||
51
tests/Media/MediaServiceEdgeCasesTest.php
Normal file
51
tests/Media/MediaServiceEdgeCasesTest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Application\MediaApplicationService;
|
||||
use Netig\Netslim\Media\Application\UseCase\DeleteMedia;
|
||||
use Netig\Netslim\Media\Application\UseCase\StoreMedia;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Media\Domain\Exception\FileTooLargeException;
|
||||
use Netig\Netslim\Media\Domain\Exception\StorageException;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaServiceEdgeCasesTest extends TestCase
|
||||
{
|
||||
public function testRejectsWhenSizeUnknown(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn(null);
|
||||
|
||||
$storage = new LocalMediaStorage('/tmp');
|
||||
$service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 1000), new DeleteMedia($repo, $storage));
|
||||
|
||||
$this->expectException(StorageException::class);
|
||||
$service->store($file, 1);
|
||||
}
|
||||
|
||||
public function testRejectsWhenFileTooLarge(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn(999999);
|
||||
$file->method('getTemporaryPath')->willReturn('/tmp/file');
|
||||
|
||||
$storage = new LocalMediaStorage('/tmp');
|
||||
$service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 100), new DeleteMedia($repo, $storage));
|
||||
|
||||
$this->expectException(FileTooLargeException::class);
|
||||
$service->store($file, 1);
|
||||
}
|
||||
}
|
||||
43
tests/Media/MediaServiceInvalidMimeTest.php
Normal file
43
tests/Media/MediaServiceInvalidMimeTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Application\MediaApplicationService;
|
||||
use Netig\Netslim\Media\Application\UseCase\DeleteMedia;
|
||||
use Netig\Netslim\Media\Application\UseCase\StoreMedia;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Media\Domain\Exception\InvalidMimeTypeException;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaServiceInvalidMimeTest extends TestCase
|
||||
{
|
||||
public function testRejectsNonImageContentEvenWithImageLikeFilename(): void
|
||||
{
|
||||
$repo = $this->createMock(MediaRepositoryInterface::class);
|
||||
$mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'upload_');
|
||||
self::assertNotFalse($tmpFile);
|
||||
file_put_contents($tmpFile, 'not an image');
|
||||
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn(filesize($tmpFile));
|
||||
$file->method('getTemporaryPath')->willReturn($tmpFile);
|
||||
|
||||
$storage = new LocalMediaStorage(sys_get_temp_dir());
|
||||
$service = new MediaApplicationService($repo, $mediaUsageReader, new StoreMedia($repo, $storage, '/media', 500000), new DeleteMedia($repo, $storage));
|
||||
|
||||
try {
|
||||
$this->expectException(InvalidMimeTypeException::class);
|
||||
$service->store($file, 1);
|
||||
} finally {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
tests/Media/MediaServiceInvalidTempPathTest.php
Normal file
39
tests/Media/MediaServiceInvalidTempPathTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Application\MediaApplicationService;
|
||||
use Netig\Netslim\Media\Application\UseCase\DeleteMedia;
|
||||
use Netig\Netslim\Media\Application\UseCase\StoreMedia;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Media\Domain\Exception\StorageException;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class MediaServiceInvalidTempPathTest extends TestCase
|
||||
{
|
||||
public function testRejectsWhenTemporaryPathIsMissing(): void
|
||||
{
|
||||
$repository = $this->createMock(MediaRepositoryInterface::class);
|
||||
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn(128);
|
||||
$file->method('getTemporaryPath')->willReturn(null);
|
||||
|
||||
$mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
|
||||
$storage = new LocalMediaStorage(sys_get_temp_dir());
|
||||
$service = new MediaApplicationService($repository, $mediaUsageReader, new StoreMedia($repository, $storage, '/media', 500000), new DeleteMedia($repository, $storage));
|
||||
|
||||
$this->expectException(StorageException::class);
|
||||
$this->expectExceptionMessage('Impossible de localiser le fichier temporaire uploadé');
|
||||
|
||||
$service->store($file, 1);
|
||||
}
|
||||
}
|
||||
348
tests/Media/MediaServiceTest.php
Normal file
348
tests/Media/MediaServiceTest.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Application\MediaApplicationService;
|
||||
use Netig\Netslim\Media\Application\UseCase\DeleteMedia;
|
||||
use Netig\Netslim\Media\Application\UseCase\StoreMedia;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReference;
|
||||
use Netig\Netslim\Media\Domain\Entity\Media;
|
||||
use Netig\Netslim\Media\Domain\Exception\FileTooLargeException;
|
||||
use Netig\Netslim\Media\Domain\Exception\InvalidMimeTypeException;
|
||||
use Netig\Netslim\Media\Domain\Repository\MediaRepositoryInterface;
|
||||
use Netig\Netslim\Media\Domain\Service\UploadedMediaInterface;
|
||||
use Netig\Netslim\Media\Infrastructure\LocalMediaStorage;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour MediaApplicationService.
|
||||
*
|
||||
* Stratégie : les opérations sur le système de fichiers réel (finfo, GD,
|
||||
* copy, moveTo) sont exercées via de vrais fichiers JPEG temporaires ;
|
||||
* le repository reste un mock pour isoler la logique de persistance.
|
||||
*
|
||||
* Les cas couverts :
|
||||
* - rejet si taille > maxSize
|
||||
* - rejet si type MIME non autorisé
|
||||
* - déduplication : retour de l'URL existante si hash déjà connu
|
||||
* - stockage : fichier écrit sur disque, media créé en base
|
||||
* - suppression : fichier supprimé du disque et entrée retirée de la base
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class MediaServiceTest extends TestCase
|
||||
{
|
||||
/** @var MediaRepositoryInterface&MockObject */
|
||||
private MediaRepositoryInterface $repository;
|
||||
|
||||
private MediaUsageReaderInterface $mediaUsageReader;
|
||||
|
||||
private string $uploadDir;
|
||||
|
||||
private MediaApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(MediaRepositoryInterface::class);
|
||||
$this->mediaUsageReader = $this->createMock(MediaUsageReaderInterface::class);
|
||||
$this->uploadDir = sys_get_temp_dir() . '/slim_media_test_' . uniqid();
|
||||
@mkdir($this->uploadDir, 0755, true);
|
||||
|
||||
$storage = new LocalMediaStorage($this->uploadDir);
|
||||
|
||||
$this->service = new MediaApplicationService(
|
||||
mediaRepository: $this->repository,
|
||||
mediaUsageReader: $this->mediaUsageReader,
|
||||
storeMedia: new StoreMedia($this->repository, $storage, '/media', 5 * 1024 * 1024),
|
||||
deleteMedia: new DeleteMedia($this->repository, $storage),
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Nettoyage du répertoire temporaire
|
||||
foreach (glob($this->uploadDir . '/*') ?: [] as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
@rmdir($this->uploadDir);
|
||||
}
|
||||
|
||||
|
||||
// ── store — rejets ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* store() doit lever FileTooLargeException si la taille dépasse le maximum.
|
||||
*/
|
||||
public function testStoreThrowsFileTooLargeWhenOversized(): void
|
||||
{
|
||||
$file = $this->makeUploadedFile(size: 6 * 1024 * 1024);
|
||||
|
||||
$this->expectException(FileTooLargeException::class);
|
||||
|
||||
$this->service->store($file, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* store() doit lever InvalidMimeTypeException pour un type MIME non autorisé.
|
||||
*/
|
||||
public function testStoreThrowsInvalidMimeType(): void
|
||||
{
|
||||
// Créer un vrai fichier texte — finfo retournera text/plain
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_test_');
|
||||
file_put_contents($tmpFile, 'ceci est un fichier texte');
|
||||
|
||||
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
|
||||
|
||||
$this->expectException(InvalidMimeTypeException::class);
|
||||
|
||||
try {
|
||||
$this->service->store($file, 1);
|
||||
} finally {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── store — déduplication ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* store() doit retourner l'URL existante sans créer de doublon si le hash est connu.
|
||||
*/
|
||||
public function testStoreReturnsDuplicateUrl(): void
|
||||
{
|
||||
$tmpFile = $this->createMinimalJpeg();
|
||||
|
||||
$existing = new Media(7, 'existing.jpg', '/media/existing.jpg', 'existing-hash', 1);
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('findByHashForUser')
|
||||
->with($this->callback(static fn (mixed $value): bool => is_string($value) && $value !== ''), 1)
|
||||
->willReturn($existing);
|
||||
$this->repository->expects($this->never())->method('create');
|
||||
|
||||
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
|
||||
$media = $this->service->store($file, 1);
|
||||
|
||||
$this->assertSame('/media/existing.jpg', $media->getUrl());
|
||||
$this->assertSame(7, $media->getId());
|
||||
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
|
||||
// ── store — stockage nominal ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* store() doit créer le fichier sur disque et appeler repository::create().
|
||||
*/
|
||||
public function testStoreWritesFileAndCreatesRecord(): void
|
||||
{
|
||||
$tmpFile = $this->createMinimalJpeg();
|
||||
|
||||
$this->repository->method('findByHashForUser')->willReturn(null);
|
||||
$this->repository->expects($this->once())->method('create');
|
||||
|
||||
$file = $this->makeUploadedFileFromPath($tmpFile, filesize($tmpFile));
|
||||
$media = $this->service->store($file, 1);
|
||||
|
||||
$this->assertStringStartsWith('/media/', $media->getUrl());
|
||||
|
||||
// Le fichier doit exister sur le disque
|
||||
$filename = basename($media->getUrl());
|
||||
$this->assertFileExists($this->uploadDir . DIRECTORY_SEPARATOR . $filename);
|
||||
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() doit supprimer le fichier physique et appeler repository::delete().
|
||||
*/
|
||||
public function testDeleteRemovesFileAndRecord(): void
|
||||
{
|
||||
$filename = 'test_media.jpg';
|
||||
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
|
||||
file_put_contents($filePath, 'fake image data');
|
||||
|
||||
$media = new Media(42, $filename, '/media/' . $filename, 'fakehash', 1);
|
||||
|
||||
$this->repository->expects($this->once())->method('delete')->with(42)->willReturn(1);
|
||||
|
||||
$this->service->delete($media);
|
||||
|
||||
$this->assertFileDoesNotExist($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() ne doit pas lever d'exception si le fichier physique n'existe plus.
|
||||
*/
|
||||
public function testDeleteIsSilentWhenFileMissing(): void
|
||||
{
|
||||
$media = new Media(99, 'inexistant.jpg', '/media/inexistant.jpg', 'hash', 1);
|
||||
|
||||
$this->repository->expects($this->once())->method('delete')->with(99)->willReturn(1);
|
||||
|
||||
// Ne doit pas lever d'exception
|
||||
$this->service->delete($media);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit conserver le fichier si la suppression en base echoue.
|
||||
*/
|
||||
public function testDeleteKeepsFileWhenRepositoryDeleteFails(): void
|
||||
{
|
||||
$filename = 'test_media_keep.jpg';
|
||||
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
|
||||
file_put_contents($filePath, 'fake image data');
|
||||
|
||||
$media = new Media(43, $filename, '/media/' . $filename, 'fakehash', 1);
|
||||
|
||||
$this->repository->expects($this->once())
|
||||
->method('delete')
|
||||
->with(43)
|
||||
->willThrowException(new \RuntimeException('db delete failed'));
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
try {
|
||||
$this->service->delete($media);
|
||||
} finally {
|
||||
$this->assertFileExists($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() ne doit pas supprimer le fichier si la ligne base n'existe plus.
|
||||
*/
|
||||
public function testDeleteKeepsFileWhenRecordWasAlreadyMissing(): void
|
||||
{
|
||||
$filename = 'test_media_orphan.jpg';
|
||||
$filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $filename;
|
||||
file_put_contents($filePath, 'fake image data');
|
||||
|
||||
$media = new Media(44, $filename, '/media/' . $filename, 'fakehash', 1);
|
||||
|
||||
$this->repository->expects($this->once())
|
||||
->method('delete')
|
||||
->with(44)
|
||||
->willReturn(0);
|
||||
|
||||
$this->service->delete($media);
|
||||
|
||||
$this->assertFileExists($filePath);
|
||||
}
|
||||
|
||||
|
||||
// ── Lectures déléguées ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findById() doit déléguer au repository et retourner null si absent.
|
||||
*/
|
||||
public function testFindByIdReturnsNullWhenMissing(): void
|
||||
{
|
||||
$this->repository->method('findById')->willReturn(null);
|
||||
|
||||
$this->assertNull($this->service->findById(999));
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() doit retourner le média trouvé.
|
||||
*/
|
||||
public function testFindByIdReturnsMedia(): void
|
||||
{
|
||||
$media = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1);
|
||||
$this->repository->expects($this->once())->method('findById')->with(3)->willReturn($media);
|
||||
|
||||
$this->assertSame($media, $this->service->findById(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* getUsageSummary() doit déléguer au port de lecture des usages média.
|
||||
*/
|
||||
public function testGetUsageSummaryDelegatesToMediaUsageReader(): void
|
||||
{
|
||||
$media = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1);
|
||||
$reference = new MediaUsageReference(12, 'Contenu lié', '/admin/content/edit/12');
|
||||
|
||||
$this->mediaUsageReader->expects($this->once())->method('countUsagesByMediaIds')->with([3])->willReturn([3 => 2]);
|
||||
$this->mediaUsageReader->expects($this->once())->method('findUsagesByMediaIds')->with([3], 3)->willReturn([3 => [$reference]]);
|
||||
|
||||
$summary = $this->service->getUsageSummary($media, 3);
|
||||
|
||||
$this->assertSame(2, $summary['count']);
|
||||
$this->assertSame([$reference], $summary['references']);
|
||||
}
|
||||
|
||||
public function testGetUsageSummariesDelegatesToMediaUsageReaderInBatch(): void
|
||||
{
|
||||
$mediaA = new Media(3, 'photo.jpg', '/media/photo.jpg', 'abc123', 1);
|
||||
$mediaB = new Media(8, 'banner.jpg', '/media/banner.jpg', 'def456', 1);
|
||||
$reference = new MediaUsageReference(12, 'Contenu lié', '/admin/content/edit/12');
|
||||
|
||||
$this->mediaUsageReader->expects($this->once())->method('countUsagesByMediaIds')->with([3, 8])->willReturn([
|
||||
3 => 2,
|
||||
8 => 0,
|
||||
]);
|
||||
$this->mediaUsageReader->expects($this->once())->method('findUsagesByMediaIds')->with([3, 8], 4)->willReturn([
|
||||
3 => [$reference],
|
||||
]);
|
||||
|
||||
$summaries = $this->service->getUsageSummaries([$mediaA, $mediaB], 4);
|
||||
|
||||
$this->assertSame(2, $summaries[3]['count']);
|
||||
$this->assertSame([$reference], $summaries[3]['references']);
|
||||
$this->assertSame(0, $summaries[8]['count']);
|
||||
$this->assertSame([], $summaries[8]['references']);
|
||||
}
|
||||
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée un mock d'UploadedFile avec une taille donnée et un tmpPath bidon.
|
||||
*/
|
||||
private function makeUploadedFile(int $size): UploadedMediaInterface
|
||||
{
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn($size);
|
||||
$file->method('getTemporaryPath')->willReturn('/nonexistent/path');
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un mock d'UploadedFile pointant vers un fichier réel.
|
||||
*/
|
||||
private function makeUploadedFileFromPath(string $path, int $size): UploadedMediaInterface
|
||||
{
|
||||
$file = $this->createMock(UploadedMediaInterface::class);
|
||||
$file->method('getSize')->willReturn($size);
|
||||
$file->method('getTemporaryPath')->willReturn($path);
|
||||
$file->method('moveTo')->willReturnCallback(function (string $dest) use ($path): void {
|
||||
copy($path, $dest);
|
||||
});
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un fichier JPEG valide via GD (1×1 pixel).
|
||||
* Retourne le chemin du fichier temporaire.
|
||||
*/
|
||||
private function createMinimalJpeg(): string
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'slim_jpeg_') . '.jpg';
|
||||
|
||||
$img = imagecreatetruecolor(1, 1);
|
||||
imagejpeg($img, $tmpFile);
|
||||
imagedestroy($img);
|
||||
|
||||
return $tmpFile;
|
||||
}
|
||||
}
|
||||
20
tests/Media/MediaUsageReferenceTest.php
Normal file
20
tests/Media/MediaUsageReferenceTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Media;
|
||||
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReference;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MediaUsageReferenceTest extends TestCase
|
||||
{
|
||||
public function testExposesImmutableUsageReferenceData(): void
|
||||
{
|
||||
$reference = new MediaUsageReference(12, 'Contenu lié', '/admin/content/edit/12');
|
||||
|
||||
self::assertSame(12, $reference->getId());
|
||||
self::assertSame('Contenu lié', $reference->getTitle());
|
||||
self::assertSame('/admin/content/edit/12', $reference->getEditPath());
|
||||
}
|
||||
}
|
||||
29
tests/Notifications/NotificationDispatchTest.php
Normal file
29
tests/Notifications/NotificationDispatchTest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Notifications;
|
||||
|
||||
use Netig\Netslim\Notifications\Domain\Entity\NotificationDispatch;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NotificationDispatchTest extends TestCase
|
||||
{
|
||||
public function testRejectsUnsupportedStatus(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Statut de notification non supporté');
|
||||
|
||||
NotificationDispatch::fromRow([
|
||||
'id' => 1,
|
||||
'recipient' => 'user@example.test',
|
||||
'subject' => 'Sujet',
|
||||
'template' => '@Identity/emails/password-reset.twig',
|
||||
'status' => 'queued',
|
||||
'notification_key' => null,
|
||||
'error_message' => null,
|
||||
'created_at' => '2026-03-20 10:00:00',
|
||||
'sent_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
tests/Notifications/NotificationServiceTest.php
Normal file
66
tests/Notifications/NotificationServiceTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Notifications;
|
||||
|
||||
use Netig\Netslim\Kernel\Mail\Application\MailServiceInterface;
|
||||
use Netig\Netslim\Notifications\Application\NotificationApplicationService;
|
||||
use Netig\Netslim\Notifications\Infrastructure\PdoNotificationDispatchRepository;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NotificationServiceTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
/** @var MailServiceInterface&MockObject */
|
||||
private MailServiceInterface $mailer;
|
||||
|
||||
private NotificationApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$this->db->exec('CREATE TABLE notification_dispatches (id INTEGER PRIMARY KEY AUTOINCREMENT, recipient TEXT NOT NULL, subject TEXT NOT NULL, template TEXT NOT NULL, status TEXT NOT NULL, notification_key TEXT DEFAULT NULL, error_message TEXT DEFAULT NULL, created_at TEXT NOT NULL, sent_at TEXT DEFAULT NULL)');
|
||||
$this->mailer = $this->createMock(MailServiceInterface::class);
|
||||
$this->service = new NotificationApplicationService($this->mailer, new PdoNotificationDispatchRepository($this->db));
|
||||
}
|
||||
|
||||
public function testSendTemplateRecordsSuccessfulDispatch(): void
|
||||
{
|
||||
$this->mailer->expects($this->once())
|
||||
->method('send')
|
||||
->with('user@example.test', 'Sujet', '@Identity/emails/password-reset.twig', ['name' => 'Ada']);
|
||||
|
||||
$this->service->sendTemplate('user@example.test', 'Sujet', '@Identity/emails/password-reset.twig', ['name' => 'Ada'], 'password-reset');
|
||||
|
||||
$history = $this->service->recent();
|
||||
self::assertCount(1, $history);
|
||||
self::assertSame('sent', $history[0]->status);
|
||||
self::assertSame('password-reset', $history[0]->notificationKey);
|
||||
}
|
||||
|
||||
public function testFailureIsRecordedAndRethrown(): void
|
||||
{
|
||||
$this->mailer->expects($this->once())
|
||||
->method('send')
|
||||
->willThrowException(new \RuntimeException('SMTP indisponible'));
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('SMTP indisponible');
|
||||
|
||||
try {
|
||||
$this->service->sendTemplate('user@example.test', 'Sujet', '@Identity/emails/password-reset.twig');
|
||||
} finally {
|
||||
$history = $this->service->recent();
|
||||
self::assertCount(1, $history);
|
||||
self::assertSame('failed', $history[0]->status);
|
||||
self::assertSame('SMTP indisponible', $history[0]->errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
tests/Settings/SettingTest.php
Normal file
45
tests/Settings/SettingTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Settings;
|
||||
|
||||
use Netig\Netslim\Settings\Domain\Entity\Setting;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SettingTest extends TestCase
|
||||
{
|
||||
public function testSerializesAndRestoresTypedValues(): void
|
||||
{
|
||||
$setting = new Setting('site.enabled', true);
|
||||
|
||||
self::assertSame('bool', $setting->getType());
|
||||
self::assertSame('1', $setting->toStorageValue());
|
||||
|
||||
$restored = Setting::fromStorage([
|
||||
'setting_key' => 'site.enabled',
|
||||
'setting_value' => '1',
|
||||
'value_type' => 'bool',
|
||||
]);
|
||||
|
||||
self::assertTrue($restored->getValue());
|
||||
}
|
||||
|
||||
public function testRejectsEmptyKey(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
new Setting('', 'value');
|
||||
}
|
||||
|
||||
public function testRejectsUnsupportedStoredType(): void
|
||||
{
|
||||
$this->expectException(\LogicException::class);
|
||||
$this->expectExceptionMessage('Type de paramètre stocké non supporté');
|
||||
|
||||
Setting::fromStorage([
|
||||
'setting_key' => 'site.mode',
|
||||
'setting_value' => 'legacy',
|
||||
'value_type' => 'legacy-type',
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
tests/Settings/SettingsServiceTest.php
Normal file
54
tests/Settings/SettingsServiceTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Settings;
|
||||
|
||||
use Netig\Netslim\Settings\Application\SettingsApplicationService;
|
||||
use Netig\Netslim\Settings\Infrastructure\PdoSettingRepository;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SettingsServiceTest extends TestCase
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
private SettingsApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = new PDO('sqlite::memory:', options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$this->db->exec('CREATE TABLE settings (setting_key TEXT PRIMARY KEY, setting_value TEXT DEFAULT NULL, value_type TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
$this->service = new SettingsApplicationService(new PdoSettingRepository($this->db));
|
||||
}
|
||||
|
||||
public function testStoresAndReadsTypedValues(): void
|
||||
{
|
||||
$this->service->set('site.name', 'Netslim');
|
||||
$this->service->set('posts.per_page', 12);
|
||||
$this->service->set('site.enabled', true);
|
||||
|
||||
self::assertSame('Netslim', $this->service->getString('site.name'));
|
||||
self::assertSame(12, $this->service->getInt('posts.per_page'));
|
||||
self::assertTrue($this->service->getBool('site.enabled'));
|
||||
self::assertSame([
|
||||
'posts.per_page' => 12,
|
||||
'site.enabled' => true,
|
||||
'site.name' => 'Netslim',
|
||||
], $this->service->all());
|
||||
}
|
||||
|
||||
public function testDeleteRemovesSetting(): void
|
||||
{
|
||||
$this->service->set('site.name', 'Netslim');
|
||||
self::assertTrue($this->service->has('site.name'));
|
||||
|
||||
$this->service->delete('site.name');
|
||||
|
||||
self::assertFalse($this->service->has('site.name'));
|
||||
self::assertSame('fallback', $this->service->getString('site.name', 'fallback'));
|
||||
}
|
||||
}
|
||||
51
tests/Taxonomy/TaxonModelTest.php
Normal file
51
tests/Taxonomy/TaxonModelTest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Taxonomy;
|
||||
|
||||
use Netig\Netslim\Taxonomy\Domain\Entity\Taxon;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
final class TaxonModelTest extends TestCase
|
||||
{
|
||||
public function testConstructAndGettersExposeTaxonData(): void
|
||||
{
|
||||
$taxon = new Taxon(4, 'PHP', 'php');
|
||||
|
||||
self::assertSame(4, $taxon->getId());
|
||||
self::assertSame('PHP', $taxon->getName());
|
||||
self::assertSame('php', $taxon->getSlug());
|
||||
}
|
||||
|
||||
public function testFromArrayHydratesTaxon(): void
|
||||
{
|
||||
$taxon = Taxon::fromArray([
|
||||
'id' => '6',
|
||||
'name' => 'Tests',
|
||||
'slug' => 'tests',
|
||||
]);
|
||||
|
||||
self::assertSame(6, $taxon->getId());
|
||||
self::assertSame('Tests', $taxon->getName());
|
||||
self::assertSame('tests', $taxon->getSlug());
|
||||
}
|
||||
|
||||
public function testValidationRejectsEmptyName(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Le nom du terme ne peut pas être vide');
|
||||
|
||||
new Taxon(1, '', 'slug');
|
||||
}
|
||||
|
||||
public function testValidationRejectsTooLongName(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Le nom du terme ne peut pas dépasser 100 caractères');
|
||||
|
||||
new Taxon(1, str_repeat('a', 101), 'slug');
|
||||
}
|
||||
}
|
||||
336
tests/Taxonomy/TaxonRepositoryTest.php
Normal file
336
tests/Taxonomy/TaxonRepositoryTest.php
Normal file
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Taxonomy;
|
||||
|
||||
use Netig\Netslim\Taxonomy\Domain\Entity\Taxon;
|
||||
use Netig\Netslim\Taxonomy\Infrastructure\PdoTaxonRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PdoTaxonRepository.
|
||||
*
|
||||
* Vérifie que chaque méthode du dépôt construit le bon SQL,
|
||||
* lie les bons paramètres et retourne les bonnes valeurs.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class TaxonRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PdoTaxonRepository $repository;
|
||||
|
||||
/**
|
||||
* Données représentant une ligne taxon en base de données.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $rowPhp;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->repository = new PdoTaxonRepository($this->db);
|
||||
|
||||
$this->rowPhp = [
|
||||
'id' => 1,
|
||||
'name' => 'PHP',
|
||||
'slug' => 'php',
|
||||
];
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
private function stmtForRead(array $rows = [], array|false $row = false): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetchAll')->willReturn($rows);
|
||||
$stmt->method('fetch')->willReturn($row);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
private function stmtForScalar(mixed $value): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetchColumn')->willReturn($value);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
private function stmtForWrite(int $rowCount = 1): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('rowCount')->willReturn($rowCount);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
|
||||
// ── findAll ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findAll() retourne un tableau vide si aucun taxon n'existe.
|
||||
*/
|
||||
public function testFindAllReturnsEmptyArrayWhenNone(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
$this->db->method('query')->willReturn($stmt);
|
||||
|
||||
$this->assertSame([], $this->repository->findAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* findAll() retourne des instances Taxon hydratées.
|
||||
*/
|
||||
public function testFindAllReturnsTaxonInstances(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([$this->rowPhp]);
|
||||
$this->db->method('query')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findAll();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertInstanceOf(Taxon::class, $result[0]);
|
||||
$this->assertSame('PHP', $result[0]->getName());
|
||||
$this->assertSame('php', $result[0]->getSlug());
|
||||
}
|
||||
|
||||
/**
|
||||
* findAll() interroge bien la table historique `categories`.
|
||||
*/
|
||||
public function testFindAllRequestsCategoriesQuery(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('query')
|
||||
->with($this->stringContains('FROM categories'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$this->repository->findAll();
|
||||
}
|
||||
|
||||
|
||||
// ── findById ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findById() retourne null si le taxon est absent.
|
||||
*/
|
||||
public function testFindByIdReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findById(99));
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() retourne une instance Taxon si le taxon existe.
|
||||
*/
|
||||
public function testFindByIdReturnsTaxonWhenFound(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: $this->rowPhp);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findById(1);
|
||||
|
||||
$this->assertInstanceOf(Taxon::class, $result);
|
||||
$this->assertSame(1, $result->getId());
|
||||
$this->assertSame('PHP', $result->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() exécute avec le bon identifiant.
|
||||
*/
|
||||
public function testFindByIdQueriesWithCorrectId(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(42, $params, true)));
|
||||
|
||||
$this->repository->findById(42);
|
||||
}
|
||||
|
||||
|
||||
// ── findBySlug ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findBySlug() retourne null si le slug est absent.
|
||||
*/
|
||||
public function testFindBySlugReturnsNullWhenMissing(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertNull($this->repository->findBySlug('inconnu'));
|
||||
}
|
||||
|
||||
/**
|
||||
* findBySlug() retourne une instance Taxon si le slug existe.
|
||||
*/
|
||||
public function testFindBySlugReturnsTaxonWhenFound(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: $this->rowPhp);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$result = $this->repository->findBySlug('php');
|
||||
|
||||
$this->assertInstanceOf(Taxon::class, $result);
|
||||
$this->assertSame('php', $result->getSlug());
|
||||
}
|
||||
|
||||
/**
|
||||
* findBySlug() exécute avec le bon slug.
|
||||
*/
|
||||
public function testFindBySlugQueriesWithCorrectSlug(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead(row: false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array('php', $params, true)));
|
||||
|
||||
$this->repository->findBySlug('php');
|
||||
}
|
||||
|
||||
|
||||
// ── create ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() prépare un INSERT avec le nom et le slug du taxon.
|
||||
*/
|
||||
public function testCreateCallsInsertWithNameAndSlug(): void
|
||||
{
|
||||
$taxon = Taxon::fromArray($this->rowPhp);
|
||||
$stmt = $this->stmtForWrite();
|
||||
|
||||
$this->db->expects($this->once())->method('prepare')
|
||||
->with($this->stringContains('INSERT INTO categories'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(
|
||||
fn (array $data): bool =>
|
||||
$data[':name'] === $taxon->getName()
|
||||
&& $data[':slug'] === $taxon->getSlug(),
|
||||
));
|
||||
|
||||
$this->db->method('lastInsertId')->willReturn('1');
|
||||
|
||||
$this->repository->create($taxon);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() retourne l'identifiant généré par la base de données.
|
||||
*/
|
||||
public function testCreateReturnsGeneratedId(): void
|
||||
{
|
||||
$taxon = Taxon::fromArray($this->rowPhp);
|
||||
$stmt = $this->stmtForWrite();
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
$this->db->method('lastInsertId')->willReturn('7');
|
||||
|
||||
$this->assertSame(7, $this->repository->create($taxon));
|
||||
}
|
||||
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() prépare un DELETE avec le bon identifiant.
|
||||
*/
|
||||
public function testDeleteCallsDeleteWithCorrectId(): void
|
||||
{
|
||||
$stmt = $this->stmtForWrite(1);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('DELETE FROM categories'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(3, $params, true)));
|
||||
|
||||
$this->repository->delete(3);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() retourne le nombre de lignes supprimées.
|
||||
*/
|
||||
public function testDeleteReturnsDeletedRowCount(): void
|
||||
{
|
||||
$stmt = $this->stmtForWrite(1);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame(1, $this->repository->delete(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() retourne 0 si le taxon n'existait plus.
|
||||
*/
|
||||
public function testDeleteReturnsZeroWhenNotFound(): void
|
||||
{
|
||||
$stmt = $this->stmtForWrite(0);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertSame(0, $this->repository->delete(99));
|
||||
}
|
||||
|
||||
|
||||
// ── nameExists ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* nameExists() retourne true si le nom existe déjà.
|
||||
*/
|
||||
public function testNameExistsReturnsTrueWhenTaken(): void
|
||||
{
|
||||
$stmt = $this->stmtForScalar(1);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertTrue($this->repository->nameExists('PHP'));
|
||||
}
|
||||
|
||||
/**
|
||||
* nameExists() retourne false si le nom est disponible.
|
||||
*/
|
||||
public function testNameExistsReturnsFalseWhenFree(): void
|
||||
{
|
||||
$stmt = $this->stmtForScalar(false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$this->assertFalse($this->repository->nameExists('Nouveau'));
|
||||
}
|
||||
|
||||
/**
|
||||
* nameExists() exécute avec le bon nom.
|
||||
*/
|
||||
public function testNameExistsQueriesWithCorrectName(): void
|
||||
{
|
||||
$stmt = $this->stmtForScalar(false);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array('PHP', $params, true)));
|
||||
|
||||
$this->repository->nameExists('PHP');
|
||||
}
|
||||
}
|
||||
201
tests/Taxonomy/TaxonomyControllerTest.php
Normal file
201
tests/Taxonomy/TaxonomyControllerTest.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Taxonomy;
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
|
||||
use Netig\Netslim\Taxonomy\Application\TaxonomyServiceInterface;
|
||||
use Netig\Netslim\Taxonomy\Domain\Entity\Taxon;
|
||||
use Netig\Netslim\Taxonomy\UI\Http\TaxonomyController;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour TaxonomyController.
|
||||
*
|
||||
* Couvre index(), create() et delete() :
|
||||
* rendu de la liste, création réussie, erreur de création,
|
||||
* suppression avec taxon introuvable, succès et erreur métier.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class TaxonomyControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @var \Slim\Views\Twig&MockObject */
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
/** @var TaxonomyServiceInterface&MockObject */
|
||||
private TaxonomyServiceInterface $taxonomyService;
|
||||
|
||||
/** @var FlashServiceInterface&MockObject */
|
||||
private FlashServiceInterface $flash;
|
||||
|
||||
private TaxonomyController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->taxonomyService = $this->createMock(TaxonomyServiceInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
|
||||
$this->controller = new TaxonomyController(
|
||||
$this->view,
|
||||
$this->taxonomyService,
|
||||
$this->flash,
|
||||
);
|
||||
}
|
||||
|
||||
// ── index ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* index() doit rendre la vue avec la liste des taxons.
|
||||
*/
|
||||
public function testIndexRendersWithCategories(): void
|
||||
{
|
||||
$this->taxonomyService->method('findPaginated')->willReturn(new PaginatedResult([], 0, 1, 20));
|
||||
|
||||
$this->view->expects($this->once())
|
||||
->method('render')
|
||||
->with($this->anything(), '@Taxonomy/admin/index.twig', $this->anything())
|
||||
->willReturnArgument(0);
|
||||
|
||||
$res = $this->controller->index($this->makeGet('/admin/categories'), $this->makeResponse());
|
||||
|
||||
$this->assertStatus($res, 200);
|
||||
}
|
||||
|
||||
// ── create ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() doit flasher un succès et rediriger en cas de création réussie.
|
||||
*/
|
||||
public function testCreateRedirectsWithSuccessFlash(): void
|
||||
{
|
||||
$this->taxonomyService->method('create')->willReturn(1);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('taxonomy_success', $this->stringContains('PHP'));
|
||||
|
||||
$req = $this->makePost('/admin/categories/create', ['name' => 'PHP']);
|
||||
$res = $this->controller->create($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit flasher une erreur si le service lève une InvalidArgumentException.
|
||||
*/
|
||||
public function testCreateRedirectsWithErrorOnInvalidArgument(): void
|
||||
{
|
||||
$this->taxonomyService->method('create')
|
||||
->willThrowException(new \InvalidArgumentException('Ce terme existe déjà'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('taxonomy_error', 'Ce terme existe déjà');
|
||||
|
||||
$req = $this->makePost('/admin/categories/create', ['name' => 'Duplicate']);
|
||||
$res = $this->controller->create($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit flasher une erreur générique pour toute autre exception.
|
||||
*/
|
||||
public function testCreateRedirectsWithGenericErrorOnUnexpectedException(): void
|
||||
{
|
||||
$this->taxonomyService->method('create')
|
||||
->willThrowException(new \RuntimeException('DB error'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('taxonomy_error', $this->stringContains('inattendue'));
|
||||
|
||||
$req = $this->makePost('/admin/categories/create', ['name' => 'PHP']);
|
||||
$res = $this->controller->create($req, $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/categories');
|
||||
}
|
||||
|
||||
// ── delete ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() doit flasher une erreur et rediriger si le taxon est introuvable.
|
||||
*/
|
||||
public function testDeleteRedirectsWithErrorWhenNotFound(): void
|
||||
{
|
||||
$this->taxonomyService->method('findById')->willReturn(null);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('taxonomy_error', 'Terme de taxonomie introuvable');
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/categories/delete/99'),
|
||||
$this->makeResponse(),
|
||||
['id' => '99'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit flasher un succès et rediriger en cas de suppression réussie.
|
||||
*/
|
||||
public function testDeleteRedirectsWithSuccessFlash(): void
|
||||
{
|
||||
$category = new Taxon(3, 'PHP', 'php');
|
||||
$this->taxonomyService->method('findById')->willReturn($category);
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('taxonomy_success', $this->stringContains('PHP'));
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/categories/delete/3'),
|
||||
$this->makeResponse(),
|
||||
['id' => '3'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit flasher une erreur si le service refuse la suppression
|
||||
* (ex: des contenus sont rattachés au taxon).
|
||||
*/
|
||||
public function testDeleteRedirectsWithErrorWhenServiceRefuses(): void
|
||||
{
|
||||
$category = new Taxon(3, 'PHP', 'php');
|
||||
$this->taxonomyService->method('findById')->willReturn($category);
|
||||
$this->taxonomyService->method('delete')
|
||||
->willThrowException(new \InvalidArgumentException('Le terme « PHP » est encore utilisé et ne peut pas être supprimé'));
|
||||
|
||||
$this->flash->expects($this->once())->method('set')
|
||||
->with('taxonomy_error', 'Le terme « PHP » est encore utilisé et ne peut pas être supprimé');
|
||||
|
||||
$res = $this->controller->delete(
|
||||
$this->makePost('/admin/categories/delete/3'),
|
||||
$this->makeResponse(),
|
||||
['id' => '3'],
|
||||
);
|
||||
|
||||
$this->assertRedirectTo($res, '/admin/categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit passer l'identifiant de route au service findById().
|
||||
*/
|
||||
public function testDeletePassesCorrectIdToService(): void
|
||||
{
|
||||
$this->taxonomyService->expects($this->once())
|
||||
->method('findById')
|
||||
->with(7)
|
||||
->willReturn(null);
|
||||
|
||||
$this->flash->method('set');
|
||||
|
||||
$this->controller->delete(
|
||||
$this->makePost('/admin/categories/delete/7'),
|
||||
$this->makeResponse(),
|
||||
['id' => '7'],
|
||||
);
|
||||
}
|
||||
}
|
||||
52
tests/Taxonomy/TaxonomyServiceReaderTest.php
Normal file
52
tests/Taxonomy/TaxonomyServiceReaderTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Taxonomy;
|
||||
|
||||
use Netig\Netslim\Taxonomy\Application\TaxonomyServiceInterface;
|
||||
use Netig\Netslim\Taxonomy\Domain\Entity\Taxon;
|
||||
use Netig\Netslim\Taxonomy\Infrastructure\TaxonomyServiceReader;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class TaxonomyServiceReaderTest extends TestCase
|
||||
{
|
||||
/** @var TaxonomyServiceInterface&MockObject */
|
||||
private TaxonomyServiceInterface $taxonomyService;
|
||||
|
||||
private TaxonomyServiceReader $reader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->taxonomyService = $this->createMock(TaxonomyServiceInterface::class);
|
||||
$this->reader = new TaxonomyServiceReader($this->taxonomyService);
|
||||
}
|
||||
|
||||
public function testFindAllMapsCategoriesToTaxonViews(): void
|
||||
{
|
||||
$this->taxonomyService->expects($this->once())
|
||||
->method('findAll')
|
||||
->willReturn([
|
||||
new Taxon(1, 'PHP', 'php'),
|
||||
new Taxon(2, 'Slim', 'slim'),
|
||||
]);
|
||||
|
||||
$taxons = $this->reader->findAll();
|
||||
|
||||
self::assertCount(2, $taxons);
|
||||
self::assertSame('PHP', $taxons[0]->name);
|
||||
self::assertSame('slim', $taxons[1]->slug);
|
||||
}
|
||||
|
||||
public function testFindBySlugReturnsNullWhenMissing(): void
|
||||
{
|
||||
$this->taxonomyService->expects($this->once())
|
||||
->method('findBySlug')
|
||||
->with('missing')
|
||||
->willReturn(null);
|
||||
|
||||
self::assertNull($this->reader->findBySlug('missing'));
|
||||
}
|
||||
}
|
||||
203
tests/Taxonomy/TaxonomyServiceTest.php
Normal file
203
tests/Taxonomy/TaxonomyServiceTest.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Taxonomy;
|
||||
|
||||
use Netig\Netslim\Taxonomy\Application\TaxonomyApplicationService;
|
||||
use Netig\Netslim\Taxonomy\Application\UseCase\CreateTaxon;
|
||||
use Netig\Netslim\Taxonomy\Application\UseCase\DeleteTaxon;
|
||||
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
|
||||
use Netig\Netslim\Taxonomy\Domain\Entity\Taxon;
|
||||
use Netig\Netslim\Taxonomy\Domain\Repository\TaxonRepositoryInterface;
|
||||
use Netig\Netslim\Taxonomy\Domain\Service\TaxonSlugGenerator;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour TaxonomyApplicationService.
|
||||
*
|
||||
* Vérifie la création (génération de slug, unicité du nom, validation du modèle)
|
||||
* et la suppression (blocage si le terme est encore utilisé).
|
||||
* Le repository est remplacé par un mock pour isoler le service.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class TaxonomyServiceTest extends TestCase
|
||||
{
|
||||
/** @var TaxonRepositoryInterface&MockObject */
|
||||
private TaxonRepositoryInterface $repository;
|
||||
|
||||
/** @var TaxonUsageCheckerInterface&MockObject */
|
||||
private TaxonUsageCheckerInterface $taxonUsageChecker;
|
||||
|
||||
private TaxonomyApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(TaxonRepositoryInterface::class);
|
||||
$this->taxonUsageChecker = $this->createMock(TaxonUsageCheckerInterface::class);
|
||||
$this->service = new TaxonomyApplicationService(
|
||||
$this->repository,
|
||||
new CreateTaxon($this->repository, new TaxonSlugGenerator()),
|
||||
new DeleteTaxon($this->repository, $this->taxonUsageChecker),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── create ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() doit générer le slug depuis le nom et persister le terme.
|
||||
*/
|
||||
public function testCreateGeneratesSlugAndPersists(): void
|
||||
{
|
||||
$this->repository->method('nameExists')->willReturn(false);
|
||||
$this->repository->expects($this->once())
|
||||
->method('create')
|
||||
->with($this->callback(
|
||||
fn (Taxon $c) =>
|
||||
$c->getName() === 'Développement web'
|
||||
&& $c->getSlug() === 'developpement-web',
|
||||
))
|
||||
->willReturn(1);
|
||||
|
||||
$id = $this->service->create('Développement web');
|
||||
|
||||
$this->assertSame(1, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit trimmer le nom avant de générer le slug.
|
||||
*/
|
||||
public function testCreateTrimsName(): void
|
||||
{
|
||||
$this->repository->method('nameExists')->willReturn(false);
|
||||
$this->repository->expects($this->once())
|
||||
->method('create')
|
||||
->with($this->callback(fn (Taxon $c) => $c->getName() === 'PHP'))
|
||||
->willReturn(2);
|
||||
|
||||
$this->service->create(' PHP ');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit lever InvalidArgumentException si le slug généré est vide.
|
||||
*/
|
||||
public function testCreateNonAsciiNameThrowsException(): void
|
||||
{
|
||||
$this->repository->expects($this->never())->method('create');
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('slug URL valide');
|
||||
|
||||
$this->service->create('日本語');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit lever InvalidArgumentException si le nom est existe déjà.
|
||||
*/
|
||||
public function testCreateDuplicateNameThrowsException(): void
|
||||
{
|
||||
$this->repository->method('nameExists')->willReturn(true);
|
||||
$this->repository->expects($this->never())->method('create');
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('existe déjà');
|
||||
|
||||
$this->service->create('PHP');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit lever InvalidArgumentException si le nom est vide.
|
||||
*/
|
||||
public function testCreateEmptyNameThrowsException(): void
|
||||
{
|
||||
$this->repository->method('nameExists')->willReturn(false);
|
||||
$this->repository->expects($this->never())->method('create');
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->service->create('');
|
||||
}
|
||||
|
||||
/**
|
||||
* create() doit lever InvalidArgumentException si le nom dépasse 100 caractères.
|
||||
*/
|
||||
public function testCreateNameTooLongThrowsException(): void
|
||||
{
|
||||
$longName = str_repeat('a', 101);
|
||||
$this->repository->method('nameExists')->willReturn(false);
|
||||
$this->repository->expects($this->never())->method('create');
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->service->create($longName);
|
||||
}
|
||||
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() doit supprimer le terme s'il n'est pas utilisé.
|
||||
*/
|
||||
public function testDeleteSucceedsWhenTaxonIsUnused(): void
|
||||
{
|
||||
$taxon = new Taxon(5, 'PHP', 'php');
|
||||
|
||||
$this->taxonUsageChecker->expects($this->once())->method('isTaxonInUse')->with(5)->willReturn(false);
|
||||
$this->repository->expects($this->once())->method('delete')->with(5);
|
||||
|
||||
$this->service->delete($taxon);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() doit lever InvalidArgumentException si le terme est encore utilisé.
|
||||
*/
|
||||
public function testDeleteBlockedWhenTaxonIsStillUsed(): void
|
||||
{
|
||||
$taxon = new Taxon(5, 'PHP', 'php');
|
||||
|
||||
$this->taxonUsageChecker->expects($this->once())->method('isTaxonInUse')->with(5)->willReturn(true);
|
||||
$this->repository->expects($this->never())->method('delete');
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('est encore utilisé');
|
||||
|
||||
$this->service->delete($taxon);
|
||||
}
|
||||
|
||||
|
||||
// ── Lectures déléguées ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findAll() doit déléguer au repository et retourner son résultat.
|
||||
*/
|
||||
public function testFindAllDelegatesToRepository(): void
|
||||
{
|
||||
$cats = [new Taxon(1, 'PHP', 'php'), new Taxon(2, 'CSS', 'css')];
|
||||
$this->repository->method('findAll')->willReturn($cats);
|
||||
|
||||
$this->assertSame($cats, $this->service->findAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() doit retourner null si le terme n'existe pas.
|
||||
*/
|
||||
public function testFindByIdReturnsNullWhenMissing(): void
|
||||
{
|
||||
$this->repository->method('findById')->willReturn(null);
|
||||
|
||||
$this->assertNull($this->service->findById(99));
|
||||
}
|
||||
|
||||
/**
|
||||
* findBySlug() doit retourner le terme correspondant.
|
||||
*/
|
||||
public function testFindBySlugReturnsTaxonWhenFound(): void
|
||||
{
|
||||
$taxon = new Taxon(3, 'PHP', 'php');
|
||||
$this->repository->expects($this->once())->method('findBySlug')->with('php')->willReturn($taxon);
|
||||
|
||||
$this->assertSame($taxon, $this->service->findBySlug('php'));
|
||||
}
|
||||
}
|
||||
12
tests/bootstrap.php
Normal file
12
tests/bootstrap.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleRegistry;
|
||||
use Netig\Netslim\Kernel\Runtime\RuntimePaths;
|
||||
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__) . '/tests/Fixtures/Application');
|
||||
RuntimePaths::setProjectRoot(dirname(__DIR__));
|
||||
ModuleRegistry::reset();
|
||||
Reference in New Issue
Block a user