feat: Permettre la personnalisation du logo et de la couleur principale de l'établissement

Les administrateurs peuvent désormais configurer l'identité visuelle
de leur établissement : upload d'un logo (PNG/JPG, redimensionné
automatiquement via Imagick) et choix d'une couleur principale
appliquée aux boutons et à la navigation.

La couleur est validée côté client et serveur pour garantir la
conformité WCAG AA (contraste ≥ 4.5:1 sur fond blanc). Les
personnalisations sont injectées dynamiquement via CSS variables
et visibles immédiatement après sauvegarde.
This commit is contained in:
2026-02-20 19:35:43 +01:00
parent cfbe96ccf8
commit 6fd084063f
67 changed files with 4584 additions and 29 deletions

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\DeleteLogo;
use App\Administration\Application\Command\DeleteLogo\DeleteLogoCommand;
use App\Administration\Application\Command\DeleteLogo\DeleteLogoHandler;
use App\Administration\Application\Service\LogoUploader;
use App\Administration\Domain\Event\BrandingModifie;
use App\Administration\Domain\Exception\SchoolBrandingNotFoundException;
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolBrandingRepository;
use App\Administration\Infrastructure\Storage\InMemoryImageProcessor;
use App\Administration\Infrastructure\Storage\InMemoryLogoStorage;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DeleteLogoHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemorySchoolBrandingRepository $brandingRepository;
private InMemoryLogoStorage $logoStorage;
private Clock $clock;
protected function setUp(): void
{
$this->brandingRepository = new InMemorySchoolBrandingRepository();
$this->logoStorage = new InMemoryLogoStorage();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-20 10:00:00');
}
};
}
#[Test]
public function itDeletesLogoFromBranding(): void
{
$this->seedBrandingWithLogo();
$handler = $this->createHandler();
$command = new DeleteLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
);
$branding = $handler($command);
self::assertNull($branding->logoUrl);
self::assertNull($branding->logoUpdatedAt);
}
#[Test]
public function itDeletesFileFromStorage(): void
{
$this->seedBrandingWithLogo();
self::assertSame(1, $this->logoStorage->count());
$handler = $this->createHandler();
$command = new DeleteLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
);
$handler($command);
self::assertSame(0, $this->logoStorage->count());
}
#[Test]
public function itRecordsDomainEventOnDeletion(): void
{
$this->seedBrandingWithLogo();
$handler = $this->createHandler();
$command = new DeleteLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
);
$branding = $handler($command);
$events = $branding->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(BrandingModifie::class, $events[0]);
}
#[Test]
public function itPersistsBrandingAfterDeletion(): void
{
$this->seedBrandingWithLogo();
$handler = $this->createHandler();
$command = new DeleteLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
);
$handler($command);
$persisted = $this->brandingRepository->findBySchoolId(
SchoolId::fromString(self::SCHOOL_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($persisted);
self::assertNull($persisted->logoUrl);
}
#[Test]
public function itDoesNothingWhenNoLogoExists(): void
{
$this->seedBrandingWithoutLogo();
$handler = $this->createHandler();
$command = new DeleteLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
);
$branding = $handler($command);
$events = $branding->pullDomainEvents();
self::assertCount(0, $events);
}
#[Test]
public function itThrowsWhenBrandingDoesNotExist(): void
{
$handler = $this->createHandler();
$command = new DeleteLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
);
$this->expectException(SchoolBrandingNotFoundException::class);
$handler($command);
}
private function createHandler(): DeleteLogoHandler
{
return new DeleteLogoHandler(
$this->brandingRepository,
new LogoUploader($this->logoStorage, new InMemoryImageProcessor()),
$this->clock,
);
}
private function seedBrandingWithLogo(): void
{
// Store a file first so we can verify deletion
$this->logoStorage->store('fake-content', 'logos/tenant/test.png', 'image/png');
$branding = SchoolBranding::creer(
schoolId: SchoolId::fromString(self::SCHOOL_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$branding->changerLogo(
new LogoUrl('https://storage.example.com/logos/tenant/test.png'),
new DateTimeImmutable('2026-02-01 10:00:00'),
);
$branding->pullDomainEvents();
$this->brandingRepository->save($branding);
}
private function seedBrandingWithoutLogo(): void
{
$branding = SchoolBranding::creer(
schoolId: SchoolId::fromString(self::SCHOOL_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$this->brandingRepository->save($branding);
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\UpdateBranding;
use App\Administration\Application\Command\UpdateBranding\UpdateBrandingCommand;
use App\Administration\Application\Command\UpdateBranding\UpdateBrandingHandler;
use App\Administration\Domain\Event\BrandingModifie;
use App\Administration\Domain\Exception\BrandColorInvalideException;
use App\Administration\Domain\Exception\ContrasteInsuffisantException;
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
use App\Administration\Domain\Model\SchoolBranding\ContrastValidator;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolBrandingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UpdateBrandingHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemorySchoolBrandingRepository $brandingRepository;
private ContrastValidator $contrastValidator;
private Clock $clock;
protected function setUp(): void
{
$this->brandingRepository = new InMemorySchoolBrandingRepository();
$this->contrastValidator = new ContrastValidator();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-20 10:00:00');
}
};
}
#[Test]
public function itCreatesBrandingWhenNoneExists(): void
{
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
$command = new UpdateBrandingCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
primaryColor: '#1E40AF',
secondaryColor: null,
accentColor: null,
);
$branding = $handler($command);
self::assertNotNull($branding->primaryColor);
self::assertSame('#1E40AF', (string) $branding->primaryColor);
self::assertNull($branding->secondaryColor);
self::assertNull($branding->accentColor);
}
#[Test]
public function itUpdatesExistingBranding(): void
{
$this->seedBranding();
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
$command = new UpdateBrandingCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
primaryColor: '#B91C1C',
secondaryColor: '#1E40AF',
accentColor: '#60A5FA',
);
$branding = $handler($command);
self::assertNotNull($branding->primaryColor);
self::assertSame('#B91C1C', (string) $branding->primaryColor);
self::assertNotNull($branding->secondaryColor);
self::assertSame('#1E40AF', (string) $branding->secondaryColor);
self::assertNotNull($branding->accentColor);
self::assertSame('#60A5FA', (string) $branding->accentColor);
}
#[Test]
public function itResetsColorsToNull(): void
{
$this->seedBranding(primaryColor: '#1E40AF');
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
$command = new UpdateBrandingCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
primaryColor: null,
secondaryColor: null,
accentColor: null,
);
$branding = $handler($command);
self::assertNull($branding->primaryColor);
self::assertNull($branding->secondaryColor);
self::assertNull($branding->accentColor);
}
#[Test]
public function itRecordsDomainEvent(): void
{
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
$command = new UpdateBrandingCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
primaryColor: '#1E40AF',
secondaryColor: null,
accentColor: null,
);
$branding = $handler($command);
$events = $branding->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(BrandingModifie::class, $events[0]);
}
#[Test]
public function itPersistsBranding(): void
{
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
$command = new UpdateBrandingCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
primaryColor: '#1E40AF',
secondaryColor: null,
accentColor: null,
);
$handler($command);
$persisted = $this->brandingRepository->findBySchoolId(
SchoolId::fromString(self::SCHOOL_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($persisted);
self::assertNotNull($persisted->primaryColor);
self::assertSame('#1E40AF', (string) $persisted->primaryColor);
}
#[Test]
public function itRejectsColorWithInsufficientContrast(): void
{
$this->expectException(ContrasteInsuffisantException::class);
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
$command = new UpdateBrandingCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
primaryColor: '#FFFF00',
secondaryColor: null,
accentColor: null,
);
$handler($command);
}
#[Test]
public function itThrowsExceptionForInvalidColor(): void
{
$this->expectException(BrandColorInvalideException::class);
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
$command = new UpdateBrandingCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
primaryColor: 'not-a-color',
secondaryColor: null,
accentColor: null,
);
$handler($command);
}
private function seedBranding(?string $primaryColor = null): void
{
$branding = SchoolBranding::creer(
schoolId: SchoolId::fromString(self::SCHOOL_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
if ($primaryColor !== null) {
$branding->modifierCouleurs(
primaryColor: new BrandColor($primaryColor),
secondaryColor: null,
accentColor: null,
at: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$branding->pullDomainEvents();
}
$this->brandingRepository->save($branding);
}
}

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\UploadLogo;
use App\Administration\Application\Command\UploadLogo\UploadLogoCommand;
use App\Administration\Application\Command\UploadLogo\UploadLogoHandler;
use App\Administration\Application\Service\LogoUploader;
use App\Administration\Domain\Event\BrandingModifie;
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolBrandingRepository;
use App\Administration\Infrastructure\Storage\InMemoryImageProcessor;
use App\Administration\Infrastructure\Storage\InMemoryLogoStorage;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
final class UploadLogoHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemorySchoolBrandingRepository $brandingRepository;
private InMemoryLogoStorage $logoStorage;
private Clock $clock;
protected function setUp(): void
{
$this->brandingRepository = new InMemorySchoolBrandingRepository();
$this->logoStorage = new InMemoryLogoStorage();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-20 10:00:00');
}
};
}
#[Test]
public function itUploadsLogoAndCreatesBranding(): void
{
$handler = $this->createHandler();
$file = $this->createTestPngFile();
$command = new UploadLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
file: $file,
);
$branding = $handler($command);
self::assertNotNull($branding->logoUrl);
self::assertStringStartsWith('https://storage.example.com/logos/', (string) $branding->logoUrl);
}
#[Test]
public function itUploadsLogoToExistingBranding(): void
{
$this->seedBranding();
$handler = $this->createHandler();
$file = $this->createTestPngFile();
$command = new UploadLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
file: $file,
);
$branding = $handler($command);
self::assertNotNull($branding->logoUrl);
self::assertStringStartsWith('https://storage.example.com/logos/', (string) $branding->logoUrl);
}
#[Test]
public function itDeletesOldFileWhenReplacingLogo(): void
{
$this->seedBranding();
self::assertSame(1, $this->logoStorage->count());
$handler = $this->createHandler();
$file = $this->createTestPngFile();
$command = new UploadLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
file: $file,
);
$handler($command);
// Old file deleted, new file stored → still 1
self::assertSame(1, $this->logoStorage->count());
self::assertFalse($this->logoStorage->has('logos/old-tenant/old-logo.png'));
}
#[Test]
public function itRecordsDomainEvent(): void
{
$handler = $this->createHandler();
$file = $this->createTestPngFile();
$command = new UploadLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
file: $file,
);
$branding = $handler($command);
$events = $branding->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(BrandingModifie::class, $events[0]);
}
#[Test]
public function itPersistsBrandingAfterUpload(): void
{
$handler = $this->createHandler();
$file = $this->createTestPngFile();
$command = new UploadLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
file: $file,
);
$handler($command);
$persisted = $this->brandingRepository->findBySchoolId(
SchoolId::fromString(self::SCHOOL_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($persisted);
self::assertNotNull($persisted->logoUrl);
}
#[Test]
public function itStoresFileInStorage(): void
{
$handler = $this->createHandler();
$file = $this->createTestPngFile();
$command = new UploadLogoCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
file: $file,
);
$handler($command);
self::assertSame(1, $this->logoStorage->count());
}
private function createHandler(): UploadLogoHandler
{
return new UploadLogoHandler(
$this->brandingRepository,
new LogoUploader($this->logoStorage, new InMemoryImageProcessor()),
$this->clock,
);
}
private function createTestPngFile(): UploadedFile
{
// Minimal valid PNG (1x1 pixel, transparent)
$pngData = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', true,
);
$tmpFile = tempnam(sys_get_temp_dir(), 'logo_test_');
if ($tmpFile === false) {
self::fail('Failed to create temp file');
}
file_put_contents($tmpFile, $pngData);
return new UploadedFile(
$tmpFile,
'test-logo.png',
'image/png',
test: true,
);
}
private function seedBranding(): void
{
// Store an old file so we can verify it gets deleted on replacement
$this->logoStorage->store('old-content', 'logos/old-tenant/old-logo.png', 'image/png');
$branding = SchoolBranding::creer(
schoolId: SchoolId::fromString(self::SCHOOL_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$branding->changerLogo(
new LogoUrl('https://storage.example.com/logos/old-tenant/old-logo.png'),
new DateTimeImmutable('2026-02-01 10:00:00'),
);
$branding->pullDomainEvents();
$this->brandingRepository->save($branding);
}
}