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,72 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Entities\Asset;
use App\Domain\Asset\Pipeline\DerivatPipelineInterface;
use App\Domain\Asset\Pipeline\DerivatPipelineRegistry;
use App\Domain\Asset\Pipeline\ImageDerivatPipeline;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\AssetMetadata;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\MimeType;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use Tests\Support\AssetTestHelpers;
describe('DerivatPipelineRegistry', function () {
it('registers pipeline for supported formats', function () {
$registry = new DerivatPipelineRegistry();
$pipeline = new ImageDerivatPipeline(BucketName::fromString('variants'));
$registry->register($pipeline);
$asset = AssetTestHelpers::createAsset($this->clock, mime: MimeType::IMAGE_JPEG);
$found = $registry->getPipelineForAsset($asset);
expect($found)->toBe($pipeline);
});
it('returns null for unsupported format', function () {
$registry = new DerivatPipelineRegistry();
$pipeline = new ImageDerivatPipeline(BucketName::fromString('variants'));
$registry->register($pipeline);
$asset = AssetTestHelpers::createAsset($this->clock, mime: MimeType::VIDEO_MP4);
$found = $registry->getPipelineForAsset($asset);
expect($found)->toBeNull();
});
it('can register multiple pipelines', function () {
$registry = new DerivatPipelineRegistry();
$imagePipeline = new ImageDerivatPipeline(BucketName::fromString('variants'));
$registry->register($imagePipeline);
$all = $registry->getAllPipelines();
expect($all)->toContain($imagePipeline);
});
it('overwrites pipeline when same format is registered twice', function () {
$registry = new DerivatPipelineRegistry();
$pipeline1 = new ImageDerivatPipeline(BucketName::fromString('variants'));
$pipeline2 = new ImageDerivatPipeline(BucketName::fromString('custom-variants'));
$registry->register($pipeline1);
$registry->register($pipeline2);
$asset = AssetTestHelpers::createAsset($this->clock, mime: MimeType::IMAGE_JPEG);
$found = $registry->getPipelineForAsset($asset);
expect($found)->toBe($pipeline2);
});
});

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Entities\Asset;
use App\Domain\Asset\Entities\AssetVariant;
use App\Domain\Asset\Pipeline\ImageDerivatPipeline;
use App\Domain\Asset\Storage\AssetStorageInterface;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\AssetMetadata;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\MimeType;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
use Tests\Support\AssetTestHelpers;
describe('ImageDerivatPipeline', function () {
beforeEach(function () {
$this->clock = new SystemClock();
$this->variantBucket = BucketName::fromString('variants');
$this->pipeline = new ImageDerivatPipeline($this->variantBucket);
$this->storage = Mockery::mock(AssetStorageInterface::class);
});
it('returns empty array for non-image assets', function () {
$asset = AssetTestHelpers::createAsset($this->clock, mime: MimeType::VIDEO_MP4);
$content = 'video-content';
$variants = $this->pipeline->process($asset, $content, $this->storage);
expect($variants)->toBe([]);
});
it('generates variants for JPEG image', function () {
$asset = $this->createTestImageAsset(1920, 1080, MimeType::IMAGE_JPEG);
$content = $this->createTestImageContent(1920, 1080);
$this->storage->shouldReceive('put')
->atLeast()->once()
->andReturnNull();
$variants = $this->pipeline->process($asset, $content, $this->storage);
expect($variants)->toBeArray();
expect(count($variants))->toBeGreaterThan(0);
});
it('generates responsive variants', function () {
$asset = $this->createTestImageAsset(1920, 1080);
$content = $this->createTestImageContent(1920, 1080);
$this->storage->shouldReceive('put')
->atLeast()->once()
->andReturnNull();
$variants = $this->pipeline->process($asset, $content, $this->storage);
$variantNames = array_map(fn ($v) => $v->variant->toString(), $variants);
expect($variantNames)->toContain('1200w');
expect($variantNames)->toContain('800w');
});
it('generates thumbnail variants', function () {
$asset = $this->createTestImageAsset(1920, 1080);
$content = $this->createTestImageContent(1920, 1080);
$this->storage->shouldReceive('put')
->atLeast()->once()
->andReturnNull();
$variants = $this->pipeline->process($asset, $content, $this->storage);
$variantNames = array_map(fn ($v) => $v->variant->toString(), $variants);
expect($variantNames)->toContain('thumb@1x');
expect($variantNames)->toContain('thumb@2x');
});
it('creates variants with correct dimensions', function () {
$asset = $this->createTestImageAsset(1920, 1080);
$content = $this->createTestImageContent(1920, 1080);
$this->storage->shouldReceive('put')
->atLeast()->once()
->andReturnNull();
$variants = $this->pipeline->process($asset, $content, $this->storage);
foreach ($variants as $variant) {
expect($variant->meta->getWidth())->not->toBeNull();
expect($variant->meta->getHeight())->not->toBeNull();
}
});
it('creates variants in variant bucket', function () {
$asset = $this->createTestImageAsset(1920, 1080);
$content = $this->createTestImageContent(1920, 1080);
$this->storage->shouldReceive('put')
->atLeast()->once()
->andReturnNull();
$variants = $this->pipeline->process($asset, $content, $this->storage);
foreach ($variants as $variant) {
expect($variant->bucket->toString())->toBe('variants');
}
});
it('creates variants as WebP format', function () {
$asset = $this->createTestImageAsset(1920, 1080);
$content = $this->createTestImageContent(1920, 1080);
$this->storage->shouldReceive('put')
->atLeast()->once()
->andReturnNull();
$variants = $this->pipeline->process($asset, $content, $this->storage);
foreach ($variants as $variant) {
expect($variant->mime)->toBe(MimeType::IMAGE_WEBP);
}
});
it('returns supported formats', function () {
$formats = $this->pipeline->getSupportedFormats();
expect($formats)->toContain('image/jpeg');
expect($formats)->toContain('image/png');
expect($formats)->toContain('image/gif');
expect($formats)->toContain('image/webp');
});
it('handles invalid image content gracefully', function () {
$asset = $this->createTestImageAsset(1920, 1080);
$content = 'invalid-image-content';
$variants = $this->pipeline->process($asset, $content, $this->storage);
// Should return empty array or filter out nulls
expect($variants)->toBeArray();
});
function createTestImageAsset(int $width, int $height, MimeType $mime = null): Asset
{
$mime = $mime ?? MimeType::IMAGE_JPEG;
return new Asset(
id: AssetId::generate($this->clock),
bucket: BucketName::fromString('media'),
key: ObjectKey::fromString('orig/2025/01/15/test.jpg'),
mime: $mime,
bytes: FileSize::fromBytes(1024),
sha256: Hash::create('test', HashAlgorithm::SHA256),
meta: AssetMetadata::empty()->withDimensions($width, $height),
createdAt: Timestamp::now()
);
}
function createTestImageContent(int $width, int $height): string
{
$img = imagecreatetruecolor($width, $height);
ob_start();
imagejpeg($img);
$content = ob_get_clean();
imagedestroy($img);
return $content;
}
});

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Entities\Asset;
use App\Domain\Asset\Repositories\DatabaseAssetRepository;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ResultInterface;
describe('DatabaseAssetRepository', function () {
beforeEach(function () {
$this->clock = new \App\Framework\DateTime\SystemClock();
$this->connection = Mockery::mock(ConnectionInterface::class);
$this->repository = new DatabaseAssetRepository($this->connection);
});
it('saves asset to database', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$this->connection->shouldReceive('execute')
->once()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->save($asset);
});
it('finds asset by id', function () {
$assetId = AssetId::generate($this->clock);
$row = [
'id' => $assetId->toString(),
'bucket' => 'media',
'key' => 'orig/2025/01/15/test.jpg',
'mime' => 'image/jpeg',
'bytes' => 1024,
'sha256' => Hash::create('test', HashAlgorithm::SHA256)->toString(),
'meta' => json_encode(['width' => 1920, 'height' => 1080]),
'created_at' => '2025-01-15 10:00:00',
];
$result = Mockery::mock(ResultInterface::class);
$result->shouldReceive('fetch')
->once()
->andReturn($row);
$this->connection->shouldReceive('query')
->once()
->andReturn($result);
$found = $this->repository->findById($assetId);
expect($found)->toBeInstanceOf(Asset::class);
expect($found->id->equals($assetId))->toBeTrue();
});
it('finds asset by SHA256 hash', function () {
$hash = Hash::create('test-content', HashAlgorithm::SHA256);
$row = [
'id' => AssetId::generate($this->clock)->toString(),
'bucket' => 'media',
'key' => 'orig/2025/01/15/test.jpg',
'mime' => 'image/jpeg',
'bytes' => 1024,
'sha256' => $hash->toString(),
'meta' => json_encode([]),
'created_at' => '2025-01-15 10:00:00',
];
$result = Mockery::mock(ResultInterface::class);
$result->shouldReceive('fetch')
->once()
->andReturn($row);
$this->connection->shouldReceive('query')
->once()
->andReturn($result);
$found = $this->repository->findBySha256($hash);
expect($found)->toBeInstanceOf(Asset::class);
expect($found->sha256->equals($hash))->toBeTrue();
});
it('finds assets by bucket', function () {
$bucket = \App\Framework\Storage\ValueObjects\BucketName::fromString('media');
$row = [
'id' => AssetId::generate($this->clock)->toString(),
'bucket' => 'media',
'key' => 'orig/2025/01/15/test.jpg',
'mime' => 'image/jpeg',
'bytes' => 1024,
'sha256' => Hash::create('test', HashAlgorithm::SHA256)->toString(),
'meta' => json_encode([]),
'created_at' => '2025-01-15 10:00:00',
];
$result = Mockery::mock(ResultInterface::class);
$result->shouldReceive('fetchAll')
->once()
->andReturn([$row]);
$this->connection->shouldReceive('query')
->once()
->andReturn($result);
$found = $this->repository->findByBucket($bucket);
expect($found)->toBeArray();
expect($found)->toHaveCount(1);
});
it('deletes asset', function () {
$assetId = AssetId::generate($this->clock);
$this->connection->shouldReceive('execute')
->once()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->delete($assetId);
});
});

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Entities\AssetTag;
use App\Domain\Asset\Repositories\DatabaseAssetTagRepository;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ResultInterface;
describe('DatabaseAssetTagRepository', function () {
beforeEach(function () {
$this->clock = new \App\Framework\DateTime\SystemClock();
$this->connection = Mockery::mock(ConnectionInterface::class);
$this->repository = new DatabaseAssetTagRepository($this->connection);
});
it('adds tag to asset', function () {
$assetId = AssetId::generate($this->clock);
$tag = 'hero';
$this->connection->shouldReceive('execute')
->once()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->addTag($assetId, $tag);
});
it('adds multiple tags to asset', function () {
$assetId = AssetId::generate($this->clock);
$tags = ['hero', 'landing-page'];
$this->connection->shouldReceive('execute')
->twice()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->addTags($assetId, $tags);
});
it('removes tag from asset', function () {
$assetId = AssetId::generate($this->clock);
$tag = 'hero';
$this->connection->shouldReceive('execute')
->once()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->removeTag($assetId, $tag);
});
it('removes multiple tags from asset', function () {
$assetId = AssetId::generate($this->clock);
$tags = ['hero', 'landing-page'];
$this->connection->shouldReceive('execute')
->twice()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->removeTags($assetId, $tags);
});
it('finds asset tags by tag name', function () {
$tag = 'hero';
$assetId = AssetId::generate($this->clock);
$row = [
'asset_id' => $assetId->toString(),
'tag' => 'hero',
];
$result = Mockery::mock(ResultInterface::class);
$result->shouldReceive('fetchAll')
->once()
->andReturn([$row]);
$this->connection->shouldReceive('query')
->once()
->andReturn($result);
$found = $this->repository->findByTag($tag);
expect($found)->toBeArray();
expect($found)->toHaveCount(1);
expect($found[0])->toBeInstanceOf(AssetTag::class);
});
it('gets all tags for asset', function () {
$assetId = AssetId::generate($this->clock);
$row = [
'asset_id' => $assetId->toString(),
'tag' => 'hero',
];
$result = Mockery::mock(ResultInterface::class);
$result->shouldReceive('fetchAll')
->once()
->andReturn([$row]);
$this->connection->shouldReceive('query')
->once()
->andReturn($result);
$found = $this->repository->getTags($assetId);
expect($found)->toBeArray();
expect($found)->toHaveCount(1);
});
it('deletes all tags for asset', function () {
$assetId = AssetId::generate($this->clock);
$this->connection->shouldReceive('execute')
->once()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->deleteByAsset($assetId);
});
});

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Entities\AssetVariant;
use App\Domain\Asset\Repositories\DatabaseAssetVariantRepository;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\VariantName;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ResultInterface;
describe('DatabaseAssetVariantRepository', function () {
beforeEach(function () {
$this->clock = new \App\Framework\DateTime\SystemClock();
$this->connection = Mockery::mock(ConnectionInterface::class);
$this->repository = new DatabaseAssetVariantRepository($this->connection);
});
it('saves variant to database', function () {
$assetId = AssetId::generate($this->clock);
$variant = AssetTestHelpers::createAssetVariant($assetId);
$this->connection->shouldReceive('execute')
->once()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->save($variant);
});
it('finds variants by asset id', function () {
$assetId = AssetId::generate($this->clock);
$row = [
'asset_id' => $assetId->toString(),
'variant' => '1200w.webp',
'bucket' => 'variants',
'key' => 'variants/2025/01/15/test/1200w.webp',
'mime' => 'image/webp',
'bytes' => 512,
'meta' => json_encode(['width' => 1200, 'height' => 675]),
];
$result = Mockery::mock(ResultInterface::class);
$result->shouldReceive('fetchAll')
->once()
->andReturn([$row]);
$this->connection->shouldReceive('query')
->once()
->andReturn($result);
$found = $this->repository->findByAsset($assetId);
expect($found)->toBeArray();
expect($found)->toHaveCount(1);
expect($found[0])->toBeInstanceOf(AssetVariant::class);
});
it('finds specific variant by asset id and variant name', function () {
$assetId = AssetId::generate($this->clock);
$variantName = VariantName::fromString('1200w.webp');
$row = [
'asset_id' => $assetId->toString(),
'variant' => '1200w.webp',
'bucket' => 'variants',
'key' => 'variants/2025/01/15/test/1200w.webp',
'mime' => 'image/webp',
'bytes' => 512,
'meta' => json_encode(['width' => 1200, 'height' => 675]),
];
$result = Mockery::mock(ResultInterface::class);
$result->shouldReceive('fetch')
->once()
->andReturn($row);
$this->connection->shouldReceive('query')
->once()
->andReturn($result);
$found = $this->repository->findByAssetAndVariant($assetId, $variantName);
expect($found)->toBeInstanceOf(AssetVariant::class);
expect($found->variant->equals($variantName))->toBeTrue();
});
it('deletes all variants for asset', function () {
$assetId = AssetId::generate($this->clock);
$this->connection->shouldReceive('execute')
->once()
->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class))
->andReturn(1);
$this->repository->deleteByAsset($assetId);
});
});

View File

@@ -0,0 +1,337 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Commands\ProcessDerivatCommand;
use App\Domain\Asset\Entities\Asset;
use App\Domain\Asset\Entities\AssetTag;
use App\Domain\Asset\Entities\AssetVariant;
use App\Domain\Asset\Exceptions\AssetNotFoundException;
use App\Domain\Asset\Repositories\AssetRepository;
use App\Domain\Asset\Repositories\AssetTagRepository;
use App\Domain\Asset\Repositories\AssetVariantRepository;
use App\Domain\Asset\Services\AssetService;
use App\Domain\Asset\Services\DeduplicationService;
use App\Domain\Asset\Services\MetadataExtractor;
use App\Domain\Asset\Storage\AssetStorageInterface;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\AssetMetadata;
use App\Domain\Asset\ValueObjects\VariantName;
use App\Framework\CommandBus\CommandBus;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\MimeType;
use App\Framework\Storage\ValueObjects\BucketName;
describe('AssetService', function () {
beforeEach(function () {
$this->clock = new SystemClock();
$this->assetRepository = Mockery::mock(AssetRepository::class);
$this->variantRepository = Mockery::mock(AssetVariantRepository::class);
$this->tagRepository = Mockery::mock(AssetTagRepository::class);
$this->storage = Mockery::mock(AssetStorageInterface::class);
$this->deduplicationService = Mockery::mock(DeduplicationService::class);
$this->metadataExtractor = Mockery::mock(MetadataExtractor::class);
$this->commandBus = Mockery::mock(CommandBus::class);
$this->service = new AssetService(
$this->assetRepository,
$this->variantRepository,
$this->tagRepository,
$this->storage,
$this->deduplicationService,
$this->metadataExtractor,
$this->commandBus,
$this->clock
);
});
it('uploads new asset successfully', function () {
$content = 'test-image-content';
$bucket = BucketName::fromString('media');
$mime = MimeType::IMAGE_JPEG;
$hash = Hash::create($content, HashAlgorithm::SHA256);
$meta = AssetMetadata::empty()->withDimensions(100, 200);
$this->deduplicationService->shouldReceive('checkDuplicate')
->once()
->with($hash)
->andReturn(null);
$this->metadataExtractor->shouldReceive('extractImageMetadata')
->once()
->with($content)
->andReturn($meta);
$this->storage->shouldReceive('put')
->once()
->andReturnNull();
$this->assetRepository->shouldReceive('save')
->once()
->andReturnNull();
$this->commandBus->shouldReceive('dispatch')
->once()
->with(Mockery::type(ProcessDerivatCommand::class))
->andReturnNull();
$asset = $this->service->upload($content, $bucket, $mime);
expect($asset)->toBeInstanceOf(Asset::class);
expect($asset->bucket->toString())->toBe('media');
expect($asset->mime)->toBe($mime);
});
it('returns existing asset when duplicate found', function () {
$content = 'test-image-content';
$bucket = BucketName::fromString('media');
$mime = MimeType::IMAGE_JPEG;
$hash = Hash::create($content, HashAlgorithm::SHA256);
$existingAsset = AssetTestHelpers::createAsset($this->clock);
$this->deduplicationService->shouldReceive('checkDuplicate')
->once()
->with($hash)
->andReturn($existingAsset);
$asset = $this->service->upload($content, $bucket, $mime);
expect($asset)->toBe($existingAsset);
});
it('adds tags to existing asset when duplicate found', function () {
$content = 'test-image-content';
$bucket = BucketName::fromString('media');
$mime = MimeType::IMAGE_JPEG;
$hash = Hash::create($content, HashAlgorithm::SHA256);
$existingAsset = AssetTestHelpers::createAsset($this->clock);
$tags = ['hero', 'landing-page'];
$this->deduplicationService->shouldReceive('checkDuplicate')
->once()
->with($hash)
->andReturn($existingAsset);
$this->tagRepository->shouldReceive('addTags')
->once()
->with($existingAsset->id, $tags)
->andReturnNull();
$asset = $this->service->upload($content, $bucket, $mime, null, $tags);
expect($asset)->toBe($existingAsset);
});
it('adds tags to new asset', function () {
$content = 'test-image-content';
$bucket = BucketName::fromString('media');
$mime = MimeType::IMAGE_JPEG;
$hash = Hash::create($content, HashAlgorithm::SHA256);
$meta = AssetMetadata::empty();
$tags = ['hero', 'landing-page'];
$this->deduplicationService->shouldReceive('checkDuplicate')
->once()
->with($hash)
->andReturn(null);
$this->metadataExtractor->shouldReceive('extractImageMetadata')
->once()
->andReturn($meta);
$this->storage->shouldReceive('put')
->once()
->andReturnNull();
$this->assetRepository->shouldReceive('save')
->once()
->andReturnNull();
$this->tagRepository->shouldReceive('addTags')
->once()
->andReturnNull();
$this->commandBus->shouldReceive('dispatch')
->once()
->andReturnNull();
$asset = $this->service->upload($content, $bucket, $mime, null, $tags);
expect($asset)->toBeInstanceOf(Asset::class);
});
it('finds asset by id', function () {
$assetId = AssetId::generate($this->clock);
$asset = AssetTestHelpers::createAsset($this->clock, $assetId);
$this->assetRepository->shouldReceive('findById')
->once()
->with($assetId)
->andReturn($asset);
$found = $this->service->findById($assetId);
expect($found)->toBe($asset);
});
it('throws exception when asset not found', function () {
$assetId = AssetId::generate($this->clock);
$this->assetRepository->shouldReceive('findById')
->once()
->with($assetId)
->andReturn(null);
expect(fn () => $this->service->findById($assetId))
->toThrow(AssetNotFoundException::class);
});
it('gets variants for asset', function () {
$assetId = AssetId::generate($this->clock);
$variants = [
AssetTestHelpers::createAssetVariant($assetId),
];
$this->variantRepository->shouldReceive('findByAsset')
->once()
->with($assetId)
->andReturn($variants);
$found = $this->service->getVariants($assetId);
expect($found)->toBe($variants);
});
it('gets specific variant', function () {
$assetId = AssetId::generate($this->clock);
$variantName = VariantName::fromString('1200w.webp');
$variant = AssetTestHelpers::createAssetVariant($assetId, $variantName);
$this->variantRepository->shouldReceive('findByAssetAndVariant')
->once()
->with($assetId, $variantName)
->andReturn($variant);
$found = $this->service->getVariant($assetId, $variantName);
expect($found)->toBe($variant);
});
it('finds assets by tag', function () {
$tag = 'hero';
$asset = AssetTestHelpers::createAsset($this->clock);
$assetTag = new AssetTag($asset->id, $tag);
$this->tagRepository->shouldReceive('findByTag')
->once()
->with($tag)
->andReturn([$assetTag]);
$this->assetRepository->shouldReceive('findById')
->once()
->with($asset->id)
->andReturn($asset);
$found = $this->service->findByTag($tag);
expect($found)->toHaveCount(1);
expect($found[0])->toBe($asset);
});
it('finds assets by bucket', function () {
$bucket = BucketName::fromString('media');
$assets = [
AssetTestHelpers::createAsset($this->clock),
];
$this->assetRepository->shouldReceive('findByBucket')
->once()
->with($bucket)
->andReturn($assets);
$found = $this->service->findByBucket($bucket);
expect($found)->toBe($assets);
});
it('deletes asset and all variants', function () {
$assetId = AssetId::generate($this->clock);
$asset = AssetTestHelpers::createAsset($this->clock, $assetId);
$variant = AssetTestHelpers::createAssetVariant($assetId);
$this->assetRepository->shouldReceive('findById')
->once()
->with($assetId)
->andReturn($asset);
$this->storage->shouldReceive('delete')
->twice()
->andReturnNull();
$this->variantRepository->shouldReceive('findByAsset')
->once()
->with($assetId)
->andReturn([$variant]);
$this->variantRepository->shouldReceive('deleteByAsset')
->once()
->with($assetId)
->andReturnNull();
$this->tagRepository->shouldReceive('deleteByAsset')
->once()
->with($assetId)
->andReturnNull();
$this->assetRepository->shouldReceive('delete')
->once()
->with($assetId)
->andReturnNull();
$this->service->delete($assetId);
});
it('adds tags to asset', function () {
$assetId = AssetId::generate($this->clock);
$tags = ['hero', 'landing-page'];
$this->tagRepository->shouldReceive('addTags')
->once()
->with($assetId, $tags)
->andReturnNull();
$this->service->addTags($assetId, $tags);
});
it('removes tags from asset', function () {
$assetId = AssetId::generate($this->clock);
$tags = ['hero'];
$this->tagRepository->shouldReceive('removeTags')
->once()
->with($assetId, $tags)
->andReturnNull();
$this->service->removeTags($assetId, $tags);
});
it('gets tags for asset', function () {
$assetId = AssetId::generate($this->clock);
$tags = [
new AssetTag($assetId, 'hero'),
new AssetTag($assetId, 'landing-page'),
];
$this->tagRepository->shouldReceive('getTags')
->once()
->with($assetId)
->andReturn($tags);
$found = $this->service->getTags($assetId);
expect($found)->toBe($tags);
});
});

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Entities\Asset;
use App\Domain\Asset\Repositories\AssetRepository;
use App\Domain\Asset\Services\DeduplicationService;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\AssetMetadata;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\MimeType;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
describe('DeduplicationService', function () {
beforeEach(function () {
$this->repository = Mockery::mock(AssetRepository::class);
$this->service = new DeduplicationService($this->repository);
});
it('returns null when no duplicate found', function () {
$hash = Hash::create('test-content', HashAlgorithm::SHA256);
$this->repository->shouldReceive('findBySha256')
->once()
->with($hash)
->andReturn(null);
$result = $this->service->checkDuplicate($hash);
expect($result)->toBeNull();
});
it('returns existing asset when duplicate found', function () {
$clock = new SystemClock();
$hash = Hash::create('test-content', HashAlgorithm::SHA256);
$asset = AssetTestHelpers::createAsset($clock);
$this->repository->shouldReceive('findBySha256')
->once()
->with($hash)
->andReturn($asset);
$result = $this->service->checkDuplicate($hash);
expect($result)->toBe($asset);
});
it('returns false when no duplicate exists', function () {
$hash = Hash::create('test-content', HashAlgorithm::SHA256);
$this->repository->shouldReceive('findBySha256')
->once()
->with($hash)
->andReturn(null);
expect($this->service->isDuplicate($hash))->toBeFalse();
});
it('returns true when duplicate exists', function () {
$clock = new SystemClock();
$hash = Hash::create('test-content', HashAlgorithm::SHA256);
$asset = AssetTestHelpers::createAsset($clock);
$this->repository->shouldReceive('findBySha256')
->once()
->with($hash)
->andReturn($asset);
expect($this->service->isDuplicate($hash))->toBeTrue();
});
});

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Services\MetadataExtractor;
describe('MetadataExtractor', function () {
beforeEach(function () {
$this->extractor = new MetadataExtractor();
});
it('extracts image dimensions from valid image', function () {
// Create a minimal valid JPEG (1x1 pixel)
$imageContent = "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xFF\xD9";
// Create a real 1x1 pixel image using GD
$img = imagecreatetruecolor(100, 200);
ob_start();
imagejpeg($img);
$imageContent = ob_get_clean();
imagedestroy($img);
$meta = $this->extractor->extractImageMetadata($imageContent);
expect($meta->getWidth())->toBe(100);
expect($meta->getHeight())->toBe(200);
});
it('returns empty metadata for invalid image content', function () {
$meta = $this->extractor->extractImageMetadata('invalid-image-content');
expect($meta->getWidth())->toBeNull();
expect($meta->getHeight())->toBeNull();
});
it('extracts EXIF data when available', function () {
// Create a minimal JPEG
$img = imagecreatetruecolor(100, 100);
ob_start();
imagejpeg($img);
$imageContent = ob_get_clean();
imagedestroy($img);
$meta = $this->extractor->extractImageMetadata($imageContent);
// EXIF might not be available in test environment, but method should not throw
expect($meta)->toBeInstanceOf(\App\Domain\Asset\ValueObjects\AssetMetadata::class);
});
it('returns empty metadata for video content', function () {
$meta = $this->extractor->extractVideoMetadata('video-content');
expect($meta->toArray())->toBe([]);
});
it('returns empty metadata for audio content', function () {
$meta = $this->extractor->extractAudioMetadata('audio-content');
expect($meta->toArray())->toBe([]);
});
it('returns null for blurhash generation', function () {
$img = imagecreatetruecolor(100, 100);
ob_start();
imagejpeg($img);
$imageContent = ob_get_clean();
imagedestroy($img);
$blurhash = $this->extractor->generateBlurhash($imageContent);
expect($blurhash)->toBeNull();
});
it('returns null for dominant color extraction', function () {
$img = imagecreatetruecolor(100, 100);
ob_start();
imagejpeg($img);
$imageContent = ob_get_clean();
imagedestroy($img);
$color = $this->extractor->extractDominantColor($imageContent);
expect($color)->toBeNull();
});
});

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use App\Domain\Asset\Entities\Asset;
use App\Domain\Asset\Storage\ObjectStorageAdapter;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\AssetMetadata;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\MimeType;
use App\Framework\Storage\ObjectStorage;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
describe('ObjectStorageAdapter', function () {
beforeEach(function () {
$this->clock = new SystemClock();
$this->objectStorage = Mockery::mock(ObjectStorage::class);
$this->adapter = new ObjectStorageAdapter($this->objectStorage);
});
it('puts asset content to storage', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$content = 'test-content';
$this->objectStorage->shouldReceive('put')
->once()
->with($asset->bucket->toString(), $asset->key->toString(), $content)
->andReturnNull();
$this->adapter->put($asset, $content);
});
it('gets asset content from storage', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$content = 'test-content';
$this->objectStorage->shouldReceive('get')
->once()
->with($asset->bucket->toString(), $asset->key->toString())
->andReturn($content);
$result = $this->adapter->get($asset);
expect($result)->toBe($content);
});
it('checks if asset exists in storage', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$this->objectStorage->shouldReceive('exists')
->once()
->with($asset->bucket->toString(), $asset->key->toString())
->andReturn(true);
expect($this->adapter->exists($asset))->toBeTrue();
});
it('deletes asset from storage', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$this->objectStorage->shouldReceive('delete')
->once()
->with($asset->bucket->toString(), $asset->key->toString())
->andReturnNull();
$this->adapter->delete($asset);
});
it('gets URL from object storage', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$url = 'https://storage.example.com/media/orig/test.jpg';
$this->objectStorage->shouldReceive('url')
->once()
->with($asset->bucket->toString(), $asset->key->toString())
->andReturn($url);
$result = $this->adapter->getUrl($asset);
expect($result)->toBe($url);
});
it('falls back to CDN URL when object storage returns null', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$cdnBaseUrl = 'https://cdn.example.com';
$adapter = new ObjectStorageAdapter($this->objectStorage, $cdnBaseUrl);
$this->objectStorage->shouldReceive('url')
->once()
->with($asset->bucket->toString(), $asset->key->toString())
->andReturn(null);
$result = $adapter->getUrl($asset);
expect($result)->toContain($cdnBaseUrl);
expect($result)->toContain($asset->bucket->toString());
expect($result)->toContain($asset->key->toString());
});
it('falls back to storage path when no CDN configured', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$this->objectStorage->shouldReceive('url')
->once()
->with($asset->bucket->toString(), $asset->key->toString())
->andReturn(null);
$result = $this->adapter->getUrl($asset);
expect($result)->toContain($asset->bucket->toString());
expect($result)->toContain($asset->key->toString());
});
it('gets signed URL from object storage', function () {
$asset = AssetTestHelpers::createAsset($this->clock);
$signedUrl = 'https://storage.example.com/media/orig/test.jpg?signature=abc123';
$expiresIn = 3600;
$this->objectStorage->shouldReceive('temporaryUrl')
->once()
->with(
$asset->bucket->toString(),
$asset->key->toString(),
Mockery::type(\DateInterval::class)
)
->andReturn($signedUrl);
$result = $this->adapter->getSignedUrl($asset, $expiresIn);
expect($result)->toBe($signedUrl);
});
});

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();
});
});