feat(cms,asset): add comprehensive test suite and finalize modules

- Add comprehensive test suite for CMS and Asset modules using Pest Framework
- Implement ContentTypeService::delete() protection against deletion of in-use content types
- Add CannotDeleteContentTypeInUseException for better error handling
- Fix DerivatPipelineRegistry::getAllPipelines() to handle object uniqueness correctly
- Fix VariantName::getScale() to correctly parse scales with file extensions
- Update CMS module documentation with new features, exceptions, and test coverage
- Add CmsTestHelpers and AssetTestHelpers for test data factories
- Fix BlockTypeRegistry to be immutable after construction
- Update ContentTypeService to check for associated content before deletion
- Improve BlockRendererRegistry initialization

Test coverage:
- Value Objects: All CMS and Asset value objects
- Services: ContentService, ContentTypeService, SlugGenerator, BlockValidator, ContentLocalizationService, AssetService, DeduplicationService, MetadataExtractor
- Repositories: All database repositories with mocked connections
- Rendering: Block renderers and ContentRenderer
- Controllers: API endpoints for both modules

254 tests passing, 38 remaining (mostly image processing pipeline tests)
This commit is contained in:
2025-11-10 02:12:28 +01:00
parent 74d50a29cc
commit 2d53270056
53 changed files with 5699 additions and 15 deletions

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\ValueObjects\AssetId;
use App\Framework\DateTime\SystemClock;
describe('AssetId', function () {
it('can be created from valid ULID string', function () {
$validUlid = '01ARZ3NDEKTSV4RRFFQ69G5FAV';
$assetId = AssetId::fromString($validUlid);
expect($assetId->toString())->toBe($validUlid);
expect((string) $assetId)->toBe($validUlid);
});
it('throws exception for invalid ULID format', function () {
expect(fn () => AssetId::fromString('invalid-id'))
->toThrow(InvalidArgumentException::class, 'Invalid Asset ID format');
});
it('can generate new AssetId with Clock', function () {
$clock = new SystemClock();
$assetId = AssetId::generate($clock);
expect($assetId)->toBeInstanceOf(AssetId::class);
expect($assetId->toString())->toMatch('/^[0-9A-Z]{26}$/');
});
it('generates unique IDs', function () {
$clock = new SystemClock();
$id1 = AssetId::generate($clock);
$id2 = AssetId::generate($clock);
expect($id1->toString())->not->toBe($id2->toString());
});
it('can compare two AssetIds for equality', function () {
$id1 = AssetId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV');
$id2 = AssetId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV');
$id3 = AssetId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAW');
expect($id1->equals($id2))->toBeTrue();
expect($id1->equals($id3))->toBeFalse();
});
});

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\ValueObjects\AssetMetadata;
describe('AssetMetadata', function () {
it('can be created as empty', function () {
$meta = AssetMetadata::empty();
expect($meta->toArray())->toBe([]);
});
it('can be created from array', function () {
$meta = AssetMetadata::fromArray([
'width' => 1920,
'height' => 1080,
]);
expect($meta->getWidth())->toBe(1920);
expect($meta->getHeight())->toBe(1080);
});
it('can get width and height', function () {
$meta = AssetMetadata::fromArray([
'width' => 1920,
'height' => 1080,
]);
expect($meta->getWidth())->toBe(1920);
expect($meta->getHeight())->toBe(1080);
});
it('returns null for missing dimensions', function () {
$meta = AssetMetadata::empty();
expect($meta->getWidth())->toBeNull();
expect($meta->getHeight())->toBeNull();
});
it('can set width immutably', function () {
$meta = AssetMetadata::empty();
$newMeta = $meta->withWidth(1920);
expect($newMeta->getWidth())->toBe(1920);
expect($meta->getWidth())->toBeNull(); // Original unchanged
});
it('can set height immutably', function () {
$meta = AssetMetadata::empty();
$newMeta = $meta->withHeight(1080);
expect($newMeta->getHeight())->toBe(1080);
expect($meta->getHeight())->toBeNull(); // Original unchanged
});
it('can set dimensions immutably', function () {
$meta = AssetMetadata::empty();
$newMeta = $meta->withDimensions(1920, 1080);
expect($newMeta->getWidth())->toBe(1920);
expect($newMeta->getHeight())->toBe(1080);
expect($meta->getWidth())->toBeNull(); // Original unchanged
});
it('can set duration', function () {
$meta = AssetMetadata::empty();
$newMeta = $meta->withDuration(120);
expect($newMeta->getDuration())->toBe(120);
});
it('can set EXIF data', function () {
$exif = ['ISO' => 400, 'Aperture' => 'f/2.8'];
$meta = AssetMetadata::empty();
$newMeta = $meta->withExif($exif);
expect($newMeta->getExif())->toBe($exif);
});
it('can set IPTC data', function () {
$iptc = ['Copyright' => '2025'];
$meta = AssetMetadata::empty();
$newMeta = $meta->withIptc($iptc);
expect($newMeta->getIptc())->toBe($iptc);
});
it('can set color profile', function () {
$meta = AssetMetadata::empty();
$newMeta = $meta->withColorProfile('sRGB');
expect($newMeta->getColorProfile())->toBe('sRGB');
});
it('can get color profile with snake_case key', function () {
$meta = AssetMetadata::fromArray(['color_profile' => 'sRGB']);
expect($meta->getColorProfile())->toBe('sRGB');
});
it('can set focal point', function () {
$focalPoint = ['x' => 0.5, 'y' => 0.5];
$meta = AssetMetadata::empty();
$newMeta = $meta->withFocalPoint($focalPoint);
expect($newMeta->getFocalPoint())->toBe($focalPoint);
});
it('can get focal point with snake_case key', function () {
$meta = AssetMetadata::fromArray(['focal_point' => ['x' => 0.5, 'y' => 0.5]]);
expect($meta->getFocalPoint())->not->toBeNull();
});
it('can set dominant color', function () {
$meta = AssetMetadata::empty();
$newMeta = $meta->withDominantColor('#FF5733');
expect($newMeta->getDominantColor())->toBe('#FF5733');
});
it('can get dominant color with snake_case key', function () {
$meta = AssetMetadata::fromArray(['dominant_color' => '#FF5733']);
expect($meta->getDominantColor())->toBe('#FF5733');
});
it('can set blurhash', function () {
$meta = AssetMetadata::empty();
$newMeta = $meta->withBlurhash('LGF5]+Yk^6#M@-5c,1J5@[or[Q6.');
expect($newMeta->getBlurhash())->toBe('LGF5]+Yk^6#M@-5c,1J5@[or[Q6.');
});
it('can get and set arbitrary values', function () {
$meta = AssetMetadata::fromArray(['custom' => 'value']);
expect($meta->get('custom'))->toBe('value');
expect($meta->has('custom'))->toBeTrue();
expect($meta->has('missing'))->toBeFalse();
});
it('can convert to array', function () {
$data = ['width' => 1920, 'height' => 1080];
$meta = AssetMetadata::fromArray($data);
expect($meta->toArray())->toBe($data);
});
it('can remove width by setting to null', function () {
$meta = AssetMetadata::fromArray(['width' => 1920]);
$newMeta = $meta->withWidth(null);
expect($newMeta->getWidth())->toBeNull();
});
});

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\ObjectKeyGenerator;
use App\Domain\Asset\ValueObjects\VariantName;
use App\Framework\DateTime\SystemClock;
use App\Framework\Storage\ValueObjects\ObjectKey;
describe('ObjectKeyGenerator', function () {
it('generates key for original asset', function () {
$clock = new SystemClock();
$assetId = AssetId::generate($clock);
$now = new \DateTimeImmutable();
$key = ObjectKeyGenerator::generateKey($assetId, 'jpg');
expect($key)->toBeInstanceOf(ObjectKey::class);
expect($key->toString())->toContain('orig');
expect($key->toString())->toContain($now->format('Y'));
expect($key->toString())->toContain($now->format('m'));
expect($key->toString())->toContain($now->format('d'));
expect($key->toString())->toContain($assetId->toString());
expect($key->toString())->toEndWith('.jpg');
});
it('generates key with custom prefix', function () {
$clock = new SystemClock();
$assetId = AssetId::generate($clock);
$key = ObjectKeyGenerator::generateKey($assetId, 'png', 'custom');
expect($key->toString())->toStartWith('custom/');
});
it('removes leading dot from extension', function () {
$clock = new SystemClock();
$assetId = AssetId::generate($clock);
$key1 = ObjectKeyGenerator::generateKey($assetId, '.jpg');
$key2 = ObjectKeyGenerator::generateKey($assetId, 'jpg');
expect($key1->toString())->toBe($key2->toString());
});
it('generates variant key', function () {
$clock = new SystemClock();
$assetId = AssetId::generate($clock);
$variant = VariantName::fromString('1200w.webp');
$now = new \DateTimeImmutable();
$key = ObjectKeyGenerator::generateVariantKey($assetId, $variant);
expect($key)->toBeInstanceOf(ObjectKey::class);
expect($key->toString())->toContain('variants');
expect($key->toString())->toContain($now->format('Y'));
expect($key->toString())->toContain($now->format('m'));
expect($key->toString())->toContain($now->format('d'));
expect($key->toString())->toContain($assetId->toString());
expect($key->toString())->toEndWith('/1200w.webp');
});
it('generates variant key with custom prefix', function () {
$clock = new SystemClock();
$assetId = AssetId::generate($clock);
$variant = VariantName::fromString('thumb@1x');
$key = ObjectKeyGenerator::generateVariantKey($assetId, $variant, 'custom-variants');
expect($key->toString())->toStartWith('custom-variants/');
});
it('generates keys with date-based structure', function () {
$clock = new SystemClock();
$assetId = AssetId::generate($clock);
$now = new \DateTimeImmutable();
$key = ObjectKeyGenerator::generateKey($assetId, 'jpg');
$expectedPattern = sprintf(
'/^orig\/%s\/%s\/%s\/%s\.jpg$/',
$now->format('Y'),
$now->format('m'),
$now->format('d'),
preg_quote($assetId->toString(), '/')
);
expect($key->toString())->toMatch($expectedPattern);
});
});

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\ValueObjects\VariantName;
describe('VariantName', function () {
it('can be created from valid string', function () {
$variant = VariantName::fromString('1200w.webp');
expect($variant->toString())->toBe('1200w.webp');
expect((string) $variant)->toBe('1200w.webp');
});
it('accepts variant names with scale', function () {
$variant = VariantName::fromString('thumb@1x');
expect($variant->toString())->toBe('thumb@1x');
expect($variant->getScale())->toBe('1x');
});
it('accepts variant names with scale and extension', function () {
$variant = VariantName::fromString('thumb@2x.webp');
expect($variant->toString())->toBe('thumb@2x.webp');
expect($variant->getScale())->toBe('2x');
expect($variant->getExtension())->toBe('webp');
});
it('accepts variant names without scale or extension', function () {
$variant = VariantName::fromString('cover');
expect($variant->toString())->toBe('cover');
expect($variant->getScale())->toBeNull();
expect($variant->getExtension())->toBeNull();
});
it('extracts extension correctly', function () {
expect(VariantName::fromString('image.webp')->getExtension())->toBe('webp');
expect(VariantName::fromString('image.jpg')->getExtension())->toBe('jpg');
expect(VariantName::fromString('waveform.json')->getExtension())->toBe('json');
});
it('extracts scale correctly', function () {
expect(VariantName::fromString('thumb@1x')->getScale())->toBe('1x');
expect(VariantName::fromString('thumb@2x')->getScale())->toBe('2x');
expect(VariantName::fromString('cover@3x.webp')->getScale())->toBe('3x');
});
it('throws exception for empty string', function () {
expect(fn () => VariantName::fromString(''))
->toThrow(InvalidArgumentException::class, 'Variant name cannot be empty');
});
it('throws exception for invalid format', function () {
expect(fn () => VariantName::fromString('invalid@format'))
->toThrow(InvalidArgumentException::class, 'Invalid variant name format');
expect(fn () => VariantName::fromString('UPPERCASE'))
->toThrow(InvalidArgumentException::class);
expect(fn () => VariantName::fromString('with spaces'))
->toThrow(InvalidArgumentException::class);
});
it('throws exception for variant name exceeding 100 characters', function () {
$longName = str_repeat('a', 101);
expect(fn () => VariantName::fromString($longName))
->toThrow(InvalidArgumentException::class, 'Variant name cannot exceed 100 characters');
});
it('can compare two VariantNames for equality', function () {
$variant1 = VariantName::fromString('1200w.webp');
$variant2 = VariantName::fromString('1200w.webp');
$variant3 = VariantName::fromString('800w.webp');
expect($variant1->equals($variant2))->toBeTrue();
expect($variant1->equals($variant3))->toBeFalse();
});
});