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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user