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,152 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolBranding;
use App\Administration\Domain\Exception\BrandColorInvalideException;
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class BrandColorTest extends TestCase
{
#[Test]
public function constructWithValidColor(): void
{
$color = new BrandColor('#3B82F6');
self::assertSame('#3B82F6', $color->value);
}
#[Test]
public function constructNormalizesToUppercase(): void
{
$color = new BrandColor('#3b82f6');
self::assertSame('#3B82F6', $color->value);
}
#[Test]
public function constructTrimsWhitespace(): void
{
$color = new BrandColor(' #3B82F6 ');
self::assertSame('#3B82F6', $color->value);
}
#[Test]
#[DataProvider('validColorsProvider')]
public function constructWithValidColors(string $input, string $expected): void
{
$color = new BrandColor($input);
self::assertSame($expected, $color->value);
}
/**
* @return iterable<string, array{string, string}>
*/
public static function validColorsProvider(): iterable
{
yield 'blue' => ['#3B82F6', '#3B82F6'];
yield 'red' => ['#EF4444', '#EF4444'];
yield 'green' => ['#10B981', '#10B981'];
yield 'black' => ['#000000', '#000000'];
yield 'white' => ['#FFFFFF', '#FFFFFF'];
yield 'lowercase' => ['#aabbcc', '#AABBCC'];
}
#[Test]
#[DataProvider('invalidColorsProvider')]
public function constructThrowsExceptionForInvalidColor(string $invalidColor): void
{
$this->expectException(BrandColorInvalideException::class);
new BrandColor($invalidColor);
}
/**
* @return iterable<string, array{string}>
*/
public static function invalidColorsProvider(): iterable
{
yield 'empty string' => [''];
yield 'no hash' => ['3B82F6'];
yield 'short format' => ['#FFF'];
yield 'too short' => ['#3B82F'];
yield 'too long' => ['#3B82F6F'];
yield 'invalid characters' => ['#GGGGGG'];
yield 'rgb format' => ['rgb(59,130,246)'];
yield 'named color' => ['blue'];
}
#[Test]
public function toRgbReturnsCorrectValues(): void
{
$color = new BrandColor('#3B82F6');
$rgb = $color->toRgb();
self::assertSame(59, $rgb['r']);
self::assertSame(130, $rgb['g']);
self::assertSame(246, $rgb['b']);
}
#[Test]
public function toRgbForBlack(): void
{
$color = new BrandColor('#000000');
$rgb = $color->toRgb();
self::assertSame(0, $rgb['r']);
self::assertSame(0, $rgb['g']);
self::assertSame(0, $rgb['b']);
}
#[Test]
public function toRgbForWhite(): void
{
$color = new BrandColor('#FFFFFF');
$rgb = $color->toRgb();
self::assertSame(255, $rgb['r']);
self::assertSame(255, $rgb['g']);
self::assertSame(255, $rgb['b']);
}
#[Test]
public function equalsReturnsTrueForSameValue(): void
{
$color1 = new BrandColor('#3B82F6');
$color2 = new BrandColor('#3B82F6');
self::assertTrue($color1->equals($color2));
}
#[Test]
public function equalsReturnsTrueForDifferentCase(): void
{
$color1 = new BrandColor('#3B82F6');
$color2 = new BrandColor('#3b82f6');
self::assertTrue($color1->equals($color2));
}
#[Test]
public function equalsReturnsFalseForDifferentValue(): void
{
$color1 = new BrandColor('#3B82F6');
$color2 = new BrandColor('#EF4444');
self::assertFalse($color1->equals($color2));
}
#[Test]
public function toStringReturnsValue(): void
{
$color = new BrandColor('#3B82F6');
self::assertSame('#3B82F6', (string) $color);
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolBranding;
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
use App\Administration\Domain\Model\SchoolBranding\ContrastValidator;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ContrastValidatorTest extends TestCase
{
private ContrastValidator $validator;
protected function setUp(): void
{
$this->validator = new ContrastValidator();
}
#[Test]
public function blackOnWhitePassesWcagAA(): void
{
$result = $this->validator->validate(
new BrandColor('#000000'),
new BrandColor('#FFFFFF'),
);
self::assertGreaterThanOrEqual(4.5, $result->ratio);
self::assertTrue($result->passesAA);
self::assertTrue($result->passesAALarge);
self::assertNull($result->suggestion);
}
#[Test]
public function whiteOnBlackPassesWcagAA(): void
{
$result = $this->validator->validate(
new BrandColor('#FFFFFF'),
new BrandColor('#000000'),
);
self::assertGreaterThanOrEqual(4.5, $result->ratio);
self::assertTrue($result->passesAA);
self::assertTrue($result->passesAALarge);
}
#[Test]
public function blackOnWhiteHasMaxContrast(): void
{
$result = $this->validator->validate(
new BrandColor('#000000'),
new BrandColor('#FFFFFF'),
);
self::assertEqualsWithDelta(21.0, $result->ratio, 0.1);
}
#[Test]
public function sameColorHasMinContrast(): void
{
$result = $this->validator->validate(
new BrandColor('#3B82F6'),
new BrandColor('#3B82F6'),
);
self::assertEqualsWithDelta(1.0, $result->ratio, 0.01);
self::assertFalse($result->passesAA);
self::assertFalse($result->passesAALarge);
}
#[Test]
public function lightGrayOnWhiteFailsWcagAA(): void
{
$result = $this->validator->validate(
new BrandColor('#CCCCCC'),
new BrandColor('#FFFFFF'),
);
self::assertLessThan(4.5, $result->ratio);
self::assertFalse($result->passesAA);
}
#[Test]
public function lightGrayOnWhitePassesAALarge(): void
{
$result = $this->validator->validate(
new BrandColor('#767676'),
new BrandColor('#FFFFFF'),
);
self::assertGreaterThanOrEqual(3.0, $result->ratio);
self::assertTrue($result->passesAALarge);
}
#[Test]
public function failingContrastSuggestsAlternative(): void
{
$result = $this->validator->validate(
new BrandColor('#CCCCCC'),
new BrandColor('#FFFFFF'),
);
self::assertFalse($result->passesAA);
self::assertNotNull($result->suggestion);
}
#[Test]
public function passingContrastDoesNotSuggestAlternative(): void
{
$result = $this->validator->validate(
new BrandColor('#000000'),
new BrandColor('#FFFFFF'),
);
self::assertTrue($result->passesAA);
self::assertNull($result->suggestion);
}
#[Test]
public function suggestedAlternativePassesWcagAA(): void
{
$result = $this->validator->validate(
new BrandColor('#CCCCCC'),
new BrandColor('#FFFFFF'),
);
self::assertNotNull($result->suggestion);
$suggestionResult = $this->validator->validate(
$result->suggestion,
new BrandColor('#FFFFFF'),
);
self::assertTrue($suggestionResult->passesAA);
}
#[Test]
public function blueOnWhiteCalculatesCorrectRatio(): void
{
$result = $this->validator->validate(
new BrandColor('#3B82F6'),
new BrandColor('#FFFFFF'),
);
self::assertGreaterThan(1.0, $result->ratio);
self::assertLessThan(21.0, $result->ratio);
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolBranding;
use App\Administration\Domain\Exception\LogoUrlInvalideException;
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class LogoUrlTest extends TestCase
{
#[Test]
public function constructWithValidHttpsUrl(): void
{
$url = new LogoUrl('https://s3.example.com/logos/school-logo.png');
self::assertSame('https://s3.example.com/logos/school-logo.png', $url->value);
}
#[Test]
public function constructWithValidHttpUrl(): void
{
$url = new LogoUrl('http://localhost:9000/logos/test.png');
self::assertSame('http://localhost:9000/logos/test.png', $url->value);
}
#[Test]
#[DataProvider('validUrlsProvider')]
public function constructWithValidUrls(string $input): void
{
$url = new LogoUrl($input);
self::assertSame($input, $url->value);
}
/**
* @return iterable<string, array{string}>
*/
public static function validUrlsProvider(): iterable
{
yield 'https with path' => ['https://s3.amazonaws.com/bucket/logos/school.png'];
yield 'https with query' => ['https://cdn.example.com/logo.png?v=12345'];
yield 'http localhost' => ['http://localhost:9000/logos/test.jpg'];
yield 'https with subdomain' => ['https://storage.googleapis.com/classeo/logos/abc.png'];
}
#[Test]
#[DataProvider('invalidUrlsProvider')]
public function constructThrowsExceptionForInvalidUrl(string $invalidUrl): void
{
$this->expectException(LogoUrlInvalideException::class);
new LogoUrl($invalidUrl);
}
/**
* @return iterable<string, array{string}>
*/
public static function invalidUrlsProvider(): iterable
{
yield 'empty string' => [''];
yield 'not a url' => ['not-a-url'];
yield 'just path' => ['/logos/school.png'];
yield 'missing scheme' => ['s3.example.com/logo.png'];
yield 'ftp scheme' => ['ftp://files.example.com/logo.png'];
}
#[Test]
public function equalsReturnsTrueForSameValue(): void
{
$url1 = new LogoUrl('https://s3.example.com/logo.png');
$url2 = new LogoUrl('https://s3.example.com/logo.png');
self::assertTrue($url1->equals($url2));
}
#[Test]
public function equalsReturnsFalseForDifferentValue(): void
{
$url1 = new LogoUrl('https://s3.example.com/logo1.png');
$url2 = new LogoUrl('https://s3.example.com/logo2.png');
self::assertFalse($url1->equals($url2));
}
#[Test]
public function toStringReturnsValue(): void
{
$url = new LogoUrl('https://s3.example.com/logo.png');
self::assertSame('https://s3.example.com/logo.png', (string) $url);
}
}

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolBranding;
use App\Administration\Domain\Event\BrandingModifie;
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SchoolBrandingTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
#[Test]
public function creerWithDefaultValues(): void
{
$branding = $this->creerBranding();
self::assertTrue($branding->schoolId->equals(SchoolId::fromString(self::SCHOOL_ID)));
self::assertTrue($branding->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
self::assertNull($branding->logoUrl);
self::assertNull($branding->primaryColor);
self::assertNull($branding->secondaryColor);
self::assertNull($branding->accentColor);
}
#[Test]
public function creerRecordsNoEvent(): void
{
$branding = $this->creerBranding();
self::assertCount(0, $branding->pullDomainEvents());
}
#[Test]
public function modifierCouleursUpdatesPrimaryColor(): void
{
$branding = $this->creerBranding();
$primaryColor = new BrandColor('#3B82F6');
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->modifierCouleurs(
primaryColor: $primaryColor,
secondaryColor: null,
accentColor: null,
at: $now,
);
self::assertNotNull($branding->primaryColor);
self::assertTrue($branding->primaryColor->equals($primaryColor));
self::assertNull($branding->secondaryColor);
self::assertNull($branding->accentColor);
}
#[Test]
public function modifierCouleursUpdatesAllColors(): void
{
$branding = $this->creerBranding();
$primary = new BrandColor('#3B82F6');
$secondary = new BrandColor('#1E40AF');
$accent = new BrandColor('#60A5FA');
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->modifierCouleurs(
primaryColor: $primary,
secondaryColor: $secondary,
accentColor: $accent,
at: $now,
);
self::assertNotNull($branding->primaryColor);
self::assertTrue($branding->primaryColor->equals($primary));
self::assertNotNull($branding->secondaryColor);
self::assertTrue($branding->secondaryColor->equals($secondary));
self::assertNotNull($branding->accentColor);
self::assertTrue($branding->accentColor->equals($accent));
}
#[Test]
public function modifierCouleursRecordsEvent(): void
{
$branding = $this->creerBranding();
$primaryColor = new BrandColor('#3B82F6');
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->modifierCouleurs(
primaryColor: $primaryColor,
secondaryColor: null,
accentColor: null,
at: $now,
);
$events = $branding->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(BrandingModifie::class, $events[0]);
self::assertSame($now, $events[0]->occurredOn());
self::assertTrue($branding->schoolId->value->equals($events[0]->aggregateId()));
}
#[Test]
public function modifierCouleursDoesNotRecordEventWhenNothingChanges(): void
{
$branding = $this->creerBranding();
$primaryColor = new BrandColor('#3B82F6');
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->modifierCouleurs(
primaryColor: $primaryColor,
secondaryColor: null,
accentColor: null,
at: $now,
);
$branding->pullDomainEvents();
$branding->modifierCouleurs(
primaryColor: $primaryColor,
secondaryColor: null,
accentColor: null,
at: new DateTimeImmutable('2026-02-20 11:00:00'),
);
self::assertCount(0, $branding->pullDomainEvents());
}
#[Test]
public function changerLogoSetsLogoUrl(): void
{
$branding = $this->creerBranding();
$logoUrl = new LogoUrl('https://s3.example.com/logos/school.png');
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->changerLogo($logoUrl, $now);
self::assertNotNull($branding->logoUrl);
self::assertTrue($branding->logoUrl->equals($logoUrl));
}
#[Test]
public function changerLogoRecordsEvent(): void
{
$branding = $this->creerBranding();
$logoUrl = new LogoUrl('https://s3.example.com/logos/school.png');
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->changerLogo($logoUrl, $now);
$events = $branding->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(BrandingModifie::class, $events[0]);
}
#[Test]
public function changerLogoDoesNotRecordEventWhenSame(): void
{
$branding = $this->creerBranding();
$logoUrl = new LogoUrl('https://s3.example.com/logos/school.png');
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->changerLogo($logoUrl, $now);
$branding->pullDomainEvents();
$branding->changerLogo($logoUrl, new DateTimeImmutable('2026-02-20 11:00:00'));
self::assertCount(0, $branding->pullDomainEvents());
}
#[Test]
public function supprimerLogoRemovesLogo(): void
{
$branding = $this->creerBranding();
$logoUrl = new LogoUrl('https://s3.example.com/logos/school.png');
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->changerLogo($logoUrl, $now);
$branding->pullDomainEvents();
$branding->supprimerLogo(new DateTimeImmutable('2026-02-20 11:00:00'));
self::assertNull($branding->logoUrl);
$events = $branding->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(BrandingModifie::class, $events[0]);
}
#[Test]
public function supprimerLogoDoesNotRecordEventWhenAlreadyNull(): void
{
$branding = $this->creerBranding();
$branding->supprimerLogo(new DateTimeImmutable('2026-02-20 10:00:00'));
self::assertCount(0, $branding->pullDomainEvents());
}
#[Test]
public function reinitialiserResetsAllToDefaults(): void
{
$branding = $this->creerBranding();
$now = new DateTimeImmutable('2026-02-20 10:00:00');
$branding->changerLogo(new LogoUrl('https://s3.example.com/logo.png'), $now);
$branding->modifierCouleurs(
primaryColor: new BrandColor('#3B82F6'),
secondaryColor: new BrandColor('#1E40AF'),
accentColor: new BrandColor('#60A5FA'),
at: $now,
);
$branding->pullDomainEvents();
$branding->reinitialiser(new DateTimeImmutable('2026-02-20 12:00:00'));
self::assertNull($branding->logoUrl);
self::assertNull($branding->primaryColor);
self::assertNull($branding->secondaryColor);
self::assertNull($branding->accentColor);
$events = $branding->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(BrandingModifie::class, $events[0]);
}
#[Test]
public function reinitialiserDoesNotRecordEventWhenAlreadyDefault(): void
{
$branding = $this->creerBranding();
$branding->reinitialiser(new DateTimeImmutable('2026-02-20 10:00:00'));
self::assertCount(0, $branding->pullDomainEvents());
}
#[Test]
public function estPersonnaliseReturnsFalseByDefault(): void
{
$branding = $this->creerBranding();
self::assertFalse($branding->estPersonnalise());
}
#[Test]
public function estPersonnaliseReturnsTrueWhenColorSet(): void
{
$branding = $this->creerBranding();
$branding->modifierCouleurs(
primaryColor: new BrandColor('#3B82F6'),
secondaryColor: null,
accentColor: null,
at: new DateTimeImmutable(),
);
self::assertTrue($branding->estPersonnalise());
}
#[Test]
public function estPersonnaliseReturnsTrueWhenLogoSet(): void
{
$branding = $this->creerBranding();
$branding->changerLogo(
new LogoUrl('https://s3.example.com/logo.png'),
new DateTimeImmutable(),
);
self::assertTrue($branding->estPersonnalise());
}
#[Test]
public function reconstituteFromStorage(): void
{
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
$tenantId = TenantId::fromString(self::TENANT_ID);
$logoUrl = new LogoUrl('https://s3.example.com/logo.png');
$primary = new BrandColor('#3B82F6');
$secondary = new BrandColor('#1E40AF');
$accent = new BrandColor('#60A5FA');
$createdAt = new DateTimeImmutable('2026-02-01 10:00:00');
$updatedAt = new DateTimeImmutable('2026-02-15 14:30:00');
$logoUpdatedAt = new DateTimeImmutable('2026-02-10 09:00:00');
$branding = SchoolBranding::reconstitute(
schoolId: $schoolId,
tenantId: $tenantId,
logoUrl: $logoUrl,
logoUpdatedAt: $logoUpdatedAt,
primaryColor: $primary,
secondaryColor: $secondary,
accentColor: $accent,
createdAt: $createdAt,
updatedAt: $updatedAt,
);
self::assertTrue($branding->schoolId->equals($schoolId));
self::assertTrue($branding->tenantId->equals($tenantId));
self::assertNotNull($branding->logoUrl);
self::assertTrue($branding->logoUrl->equals($logoUrl));
self::assertNotNull($branding->primaryColor);
self::assertTrue($branding->primaryColor->equals($primary));
self::assertNotNull($branding->secondaryColor);
self::assertTrue($branding->secondaryColor->equals($secondary));
self::assertNotNull($branding->accentColor);
self::assertTrue($branding->accentColor->equals($accent));
self::assertSame($createdAt, $branding->createdAt);
self::assertSame($updatedAt, $branding->updatedAt);
self::assertCount(0, $branding->pullDomainEvents());
}
private function creerBranding(): SchoolBranding
{
return SchoolBranding::creer(
schoolId: SchoolId::fromString(self::SCHOOL_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
}
}