Working state

This commit is contained in:
julien
2026-03-16 09:25:44 +01:00
parent b5a728e669
commit fd3f608059
24 changed files with 249 additions and 502 deletions

View File

@@ -6,43 +6,43 @@ namespace Tests\Shared;
use App\Shared\Bootstrap;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use ReflectionProperty;
use Slim\Factory\AppFactory;
use Slim\App;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class BootstrapTest extends TestCase
{
/** @var array<string, string> */
private array $envBackup = [];
protected function setUp(): void
{
$this->envBackup = $_ENV;
$this->envBackup = [
'APP_AUTO_PROVISION' => $_ENV['APP_AUTO_PROVISION'] ?? null,
];
}
protected function tearDown(): void
{
$_ENV = $this->envBackup;
foreach ($this->envBackup as $key => $value) {
if ($value === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $value;
}
}
}
public function testGetContainerReturnsPreloadedContainer(): void
public function testInitializeInfrastructureReturnsPreloadedContainer(): void
{
$bootstrap = Bootstrap::create();
$container = new class implements ContainerInterface {
public function get(string $id): mixed
{
throw new \RuntimeException('Not expected');
}
$container = $this->createStub(ContainerInterface::class);
public function has(string $id): bool
{
return false;
}
};
$this->setPrivate($bootstrap, 'container', $container);
$this->setPrivateProperty($bootstrap, 'container', $container);
self::assertSame($container, $bootstrap->getContainer());
self::assertSame($container, $bootstrap->initializeInfrastructure());
self::assertSame($container, $bootstrap->getContainer());
}
public function testCreateHttpAppReturnsPreloadedApp(): void
@@ -50,40 +50,29 @@ final class BootstrapTest extends TestCase
$bootstrap = Bootstrap::create();
$app = AppFactory::create();
$this->setPrivateProperty($bootstrap, 'app', $app);
$this->setPrivate($bootstrap, 'app', $app);
self::assertSame($app, $bootstrap->createHttpApp());
}
public function testInitializeReturnsPreloadedAppWhenAutoProvisioningDisabled(): void
public function testInitializeReturnsPreloadedAppWhenAutoProvisionIsDisabled(): void
{
$_ENV['APP_ENV'] = 'production';
$_ENV['APP_AUTO_PROVISION'] = '0';
$bootstrap = Bootstrap::create();
$container = new class implements ContainerInterface {
public function get(string $id): mixed
{
throw new \RuntimeException('Not expected');
}
public function has(string $id): bool
{
return false;
}
};
$container = $this->createStub(ContainerInterface::class);
$app = AppFactory::create();
$this->setPrivateProperty($bootstrap, 'container', $container);
$this->setPrivateProperty($bootstrap, 'app', $app);
$this->setPrivate($bootstrap, 'container', $container);
$this->setPrivate($bootstrap, 'app', $app);
self::assertSame($app, $bootstrap->initialize());
}
private function setPrivateProperty(object $object, string $property, mixed $value): void
private function setPrivate(Bootstrap $bootstrap, string $property, mixed $value): void
{
$reflection = new \ReflectionProperty($object, $property);
$reflection = new ReflectionProperty($bootstrap, $property);
$reflection->setAccessible(true);
$reflection->setValue($object, $value);
$reflection->setValue($bootstrap, $value);
}
}

View File

@@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ClientIpResolverTest extends TestCase
{
public function testResolveReturnsDefaultWhenRemoteAddrMissing(): void
@@ -53,27 +54,4 @@ final class ClientIpResolverTest extends TestCase
self::assertSame('127.0.0.1', $resolver->resolve($request));
}
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 testResolveTrimsWhitespaceAroundRemoteAndForwardedAddresses(): 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(['*']);
self::assertSame('203.0.113.10', $resolver->resolve($request));
}
}

View File

@@ -7,6 +7,7 @@ use App\Shared\Config;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ConfigTest extends TestCase
{
public function testGetTwigCacheReturnsFalseInDev(): void
@@ -22,25 +23,6 @@ final class ConfigTest extends TestCase
self::assertStringEndsWith('/var/cache/twig', $cachePath);
}
public function testGetDatabasePathReturnsExistingFilePathUnchanged(): void
{
$dbFile = dirname(__DIR__, 2).'/database/app.sqlite';
$dbDir = dirname($dbFile);
if (!is_dir($dbDir)) {
mkdir($dbDir, 0755, true);
}
if (!file_exists($dbFile)) {
touch($dbFile);
}
$path = Config::getDatabasePath();
self::assertSame($dbFile, $path);
self::assertFileExists($dbFile);
}
public function testGetDatabasePathCreatesDatabaseFileWhenMissing(): void
{
$dbFile = dirname(__DIR__, 2).'/database/app.sqlite';

View File

@@ -46,21 +46,6 @@ final class ExtensionTest extends TestCase
], $extension->getGlobals());
}
public function testSessionExtensionExposesNullDefaultsWhenSessionIsEmpty(): void
{
$_SESSION = [];
$extension = new SessionExtension();
self::assertSame([
'session' => [
'user_id' => null,
'username' => null,
'role' => null,
],
], $extension->getGlobals());
}
public function testCsrfExtensionExposesTokens(): void
{
$storage = [];

View File

@@ -7,6 +7,7 @@ use App\Shared\Http\FlashService;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class FlashServiceTest extends TestCase
{
protected function setUp(): void
@@ -34,26 +35,6 @@ final class FlashServiceTest extends TestCase
self::assertArrayNotHasKey('count', $_SESSION['flash']);
}
public function testGetCastsBooleanFalseToEmptyStringAndRemovesIt(): void
{
$_SESSION['flash']['flag'] = false;
$flash = new FlashService();
self::assertSame('', $flash->get('flag'));
self::assertArrayNotHasKey('flag', $_SESSION['flash']);
}
public function testSetOverridesPreviousMessageForSameKey(): void
{
$flash = new FlashService();
$flash->set('notice', 'Premier');
$flash->set('notice', 'Second');
self::assertSame('Second', $flash->get('notice'));
}
public function testGetReturnsNullWhenMissing(): void
{
$flash = new FlashService();

View File

@@ -6,17 +6,17 @@ namespace Tests\Shared;
use App\Shared\Mail\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->createService('ssl', 465);
/** @var PHPMailer $mailer */
$service = $this->makeService('ssl', 465);
$mailer = $this->invokeCreateMailer($service);
self::assertSame('smtp', $mailer->Mailer);
@@ -27,25 +27,23 @@ final class MailServiceTest extends TestCase
self::assertSame(PHPMailer::ENCRYPTION_SMTPS, $mailer->SMTPSecure);
self::assertSame(465, $mailer->Port);
self::assertSame(PHPMailer::CHARSET_UTF8, $mailer->CharSet);
self::assertSame('noreply@example.test', $mailer->From);
self::assertSame('no-reply@example.test', $mailer->From);
self::assertSame('Slim Blog', $mailer->FromName);
}
public function testCreateMailerUsesStartTlsWhenEncryptionIsNotSsl(): void
{
$service = $this->createService('tls', 587);
/** @var PHPMailer $mailer */
$service = $this->makeService('tls', 587);
$mailer = $this->invokeCreateMailer($service);
self::assertSame(PHPMailer::ENCRYPTION_STARTTLS, $mailer->SMTPSecure);
self::assertSame(587, $mailer->Port);
}
private function createService(string $encryption, int $port): MailService
private function makeService(string $encryption, int $port): MailService
{
$twig = new Twig(new ArrayLoader([
'emails/test.twig' => '<p>Hello {{ name }}</p>',
'emails/test.twig' => '<p>Bonjour {{ name }}</p>',
]));
return new MailService(
@@ -55,16 +53,19 @@ final class MailServiceTest extends TestCase
'mailer-user',
'mailer-pass',
$encryption,
'noreply@example.test',
'no-reply@example.test',
'Slim Blog',
);
}
private function invokeCreateMailer(MailService $service): mixed
private function invokeCreateMailer(MailService $service): PHPMailer
{
$method = new \ReflectionMethod($service, 'createMailer');
$method = new ReflectionMethod($service, 'createMailer');
$method->setAccessible(true);
return $method->invoke($service);
/** @var PHPMailer $mailer */
$mailer = $method->invoke($service);
return $mailer;
}
}

View File

@@ -6,12 +6,14 @@ namespace Tests\Shared;
use App\Shared\Exception\NotFoundException;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class NotFoundExceptionTest extends TestCase
{
public function testConstructorFormatsEntityAndIdentifierInMessage(): void
public function testMessageContainsEntityAndIdentifier(): void
{
$exception = new NotFoundException('Article', 15);
$exception = new NotFoundException('Article', 'mon-slug');
self::assertSame('Article introuvable : 15', $exception->getMessage());
self::assertSame('Article introuvable : mon-slug', $exception->getMessage());
}
}

View File

@@ -7,69 +7,70 @@ use App\Shared\Database\Provisioner;
use PDO;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class ProvisionerTest extends TestCase
{
private PDO $db;
private string $lockPath;
private bool $lockExistedBefore;
/** @var array<string, string> */
private array $envBackup;
private array $envBackup = [];
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
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';
$this->lockExistedBefore = file_exists($this->lockPath);
@unlink($this->lockPath);
$this->envBackup = [
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? '',
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? '',
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? '',
'ADMIN_USERNAME' => $_ENV['ADMIN_USERNAME'] ?? null,
'ADMIN_EMAIL' => $_ENV['ADMIN_EMAIL'] ?? null,
'ADMIN_PASSWORD' => $_ENV['ADMIN_PASSWORD'] ?? null,
];
$_ENV['ADMIN_USERNAME'] = 'shared-admin';
$_ENV['ADMIN_EMAIL'] = 'shared-admin@example.com';
$_ENV['ADMIN_PASSWORD'] = 'strong-secret';
$_ENV['ADMIN_USERNAME'] = 'Admin';
$_ENV['ADMIN_EMAIL'] = 'Admin@example.com';
$_ENV['ADMIN_PASSWORD'] = 'secret1234';
}
protected function tearDown(): void
{
foreach ($this->envBackup as $key => $value) {
$_ENV[$key] = $value;
}
@unlink($this->lockPath);
if (!$this->lockExistedBefore && file_exists($this->lockPath)) {
@unlink($this->lockPath);
foreach ($this->envBackup as $key => $value) {
if ($value === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $value;
}
}
}
public function testRunAppliesMigrationsSeedsAdminAndCreatesLockFile(): void
public function testRunCreatesProvisionLockAndSeedsAdminUser(): void
{
Provisioner::run($this->db);
self::assertFileExists($this->lockPath);
$migrationCount = (int) $this->db->query('SELECT COUNT(*) FROM migrations')->fetchColumn();
self::assertGreaterThan(0, $migrationCount, 'Les migrations doivent être enregistrées');
$row = $this->db->query('SELECT username, email, role FROM users')->fetch();
$admin = $this->db->query("SELECT username, email, role FROM users WHERE username = 'shared-admin'")->fetch();
self::assertIsArray($admin);
self::assertSame('shared-admin@example.com', $admin['email']);
self::assertSame('admin', $admin['role']);
self::assertIsArray($row);
self::assertSame('admin', $row['username']);
self::assertSame('admin@example.com', $row['email']);
self::assertSame('admin', $row['role']);
}
public function testRunIsIdempotentForAdminSeed(): void
public function testRunIsIdempotent(): void
{
Provisioner::run($this->db);
Provisioner::run($this->db);
$adminCount = (int) $this->db->query("SELECT COUNT(*) FROM users WHERE username = 'shared-admin'")->fetchColumn();
self::assertSame(1, $adminCount);
$count = (int) $this->db->query('SELECT COUNT(*) FROM users WHERE username = "admin"')->fetchColumn();
self::assertSame(1, $count);
}
}

View File

@@ -7,6 +7,8 @@ use App\Shared\Routes;
use PHPUnit\Framework\TestCase;
use Slim\Factory\AppFactory;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class RoutesTest extends TestCase
{
public function testRegisterDeclaresExpectedPublicAndProtectedRoutes(): void
@@ -15,46 +17,48 @@ final class RoutesTest extends TestCase
Routes::register($app);
$actual = [];
foreach ($app->getRouteCollector()->getRoutes() as $route) {
$pattern = $route->getPattern();
$methods = $route->getMethods();
if (!isset($actual[$pattern])) {
$actual[$pattern] = [];
}
$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 = [
'/' => ['GET'],
'/account/password' => ['GET', 'POST'],
'/admin' => ['GET'],
'/admin/categories' => ['GET'],
'/admin/categories/create' => ['POST'],
'/admin/categories/delete/{id}' => ['POST'],
'/admin/media' => ['GET'],
'/admin/media/delete/{id}' => ['POST'],
'/admin/media/upload' => ['POST'],
'/admin/posts' => ['GET'],
'/admin/posts/create' => ['POST'],
'/admin/posts/delete/{id}' => ['POST'],
'/admin/posts/edit/{id}' => ['GET', 'POST'],
'/admin/users' => ['GET'],
'/admin/users/create' => ['GET', 'POST'],
'/admin/users/delete/{id}' => ['POST'],
'/admin/users/role/{id}' => ['POST'],
'/article/{slug}' => ['GET'],
'/rss.xml' => ['GET'],
'/auth/login' => ['GET', 'POST'],
'/auth/logout' => ['POST'],
'/password/forgot' => ['GET', 'POST'],
'/password/reset' => ['GET', 'POST'],
'/account/password' => ['GET', 'POST'],
'/admin' => ['GET'],
'/admin/posts' => ['GET'],
'/admin/posts/edit/{id}' => ['GET', 'POST'],
'/admin/posts/create' => ['POST'],
'/admin/posts/delete/{id}' => ['POST'],
'/admin/media/upload' => ['POST'],
'/admin/media' => ['GET'],
'/admin/media/delete/{id}' => ['POST'],
'/admin/categories' => ['GET'],
'/admin/categories/create' => ['POST'],
'/admin/categories/delete/{id}' => ['POST'],
'/admin/users' => ['GET'],
'/admin/users/create' => ['GET', 'POST'],
'/admin/users/role/{id}' => ['POST'],
'/admin/users/delete/{id}' => ['POST'],
'/rss.xml' => ['GET'],
];
foreach ($expected as $pattern => $methods) {
sort($methods);
}
ksort($expected);
ksort($actual);
self::assertSame($expected, $actual);
}

View File

@@ -7,6 +7,7 @@ use App\Shared\Http\SessionManager;
use PHPUnit\Framework\TestCase;
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class SessionManagerEdgeCasesTest extends TestCase
{
private SessionManager $manager;
@@ -30,14 +31,6 @@ final class SessionManagerEdgeCasesTest extends TestCase
self::assertFalse($this->manager->isAuthenticated());
}
public function testGetUserIdCastsNumericStringToInteger(): void
{
$_SESSION['user_id'] = '42';
self::assertSame(42, $this->manager->getUserId());
self::assertTrue($this->manager->isAuthenticated());
}
public function testSetUserUsesDefaultRoleUser(): void
{
$this->manager->setUser(12, 'julien');