diff --git a/src/Domain/Asset/Pipeline/DerivatPipelineRegistry.php b/src/Domain/Asset/Pipeline/DerivatPipelineRegistry.php index c5b08e2e..3e23d04e 100644 --- a/src/Domain/Asset/Pipeline/DerivatPipelineRegistry.php +++ b/src/Domain/Asset/Pipeline/DerivatPipelineRegistry.php @@ -28,7 +28,14 @@ final class DerivatPipelineRegistry */ public function getAllPipelines(): array { - return array_unique(array_values($this->pipelines)); + $unique = []; + foreach (array_values($this->pipelines) as $pipeline) { + $key = spl_object_hash($pipeline); + if (!isset($unique[$key])) { + $unique[$key] = $pipeline; + } + } + return array_values($unique); } } diff --git a/src/Domain/Asset/ValueObjects/VariantName.php b/src/Domain/Asset/ValueObjects/VariantName.php index 6d5a8fac..333f4007 100644 --- a/src/Domain/Asset/ValueObjects/VariantName.php +++ b/src/Domain/Asset/ValueObjects/VariantName.php @@ -46,7 +46,7 @@ final readonly class VariantName public function getScale(): ?string { - if (preg_match('/@([0-9]+x)$/', $this->value, $matches)) { + if (preg_match('/@([0-9]+x)(\.[a-z0-9]+)?$/', $this->value, $matches)) { return $matches[1]; } diff --git a/src/Domain/Cms/DI/CmsServiceInitializer.php b/src/Domain/Cms/DI/CmsServiceInitializer.php index bb62f6d9..e56b0d74 100644 --- a/src/Domain/Cms/DI/CmsServiceInitializer.php +++ b/src/Domain/Cms/DI/CmsServiceInitializer.php @@ -88,6 +88,7 @@ final readonly class CmsServiceInitializer ContentTypeService::class, fn (Container $c) => new ContentTypeService( $c->get(ContentTypeRepository::class), + $c->get(ContentRepository::class), $c->get(Clock::class) ) ); diff --git a/src/Domain/Cms/Exceptions/CannotDeleteContentTypeInUseException.php b/src/Domain/Cms/Exceptions/CannotDeleteContentTypeInUseException.php new file mode 100644 index 00000000..b4d3f2c9 --- /dev/null +++ b/src/Domain/Cms/Exceptions/CannotDeleteContentTypeInUseException.php @@ -0,0 +1,25 @@ +toString(), + $contentCount + ), + (int) ValidationErrorCode::BUSINESS_RULE_VIOLATION->getNumericCode() + ); + } +} + diff --git a/src/Domain/Cms/README.md b/src/Domain/Cms/README.md index 71b2caf6..007cc6d1 100644 --- a/src/Domain/Cms/README.md +++ b/src/Domain/Cms/README.md @@ -106,6 +106,37 @@ $updated = $contentService->updateBlocks( ); ``` +### ContentTypeService + +```php +use App\Domain\Cms\Services\ContentTypeService; +use App\Domain\Cms\ValueObjects\ContentTypeId; + +// Content Type erstellen +$contentType = $contentTypeService->create( + name: 'Landing Page', + slug: 'landing_page', + description: 'Landing pages for marketing campaigns', + isSystem: false +); + +// Content Type aktualisieren +$updated = $contentTypeService->update( + id: ContentTypeId::fromString('landing_page'), + name: 'Updated Name', + description: 'New description' +); + +// Content Type löschen +// Wirft CannotDeleteContentTypeInUseException wenn noch Content existiert +// Wirft CannotDeleteSystemContentTypeException für System-Types +try { + $contentTypeService->delete(ContentTypeId::fromString('landing_page')); +} catch (CannotDeleteContentTypeInUseException $e) { + // Content Type wird noch von Content verwendet +} +``` + ## REST API ### Endpoints @@ -191,3 +222,141 @@ php console.php db:migrate Die Migrations erstellen die Tabellen `content_types` und `contents` mit JSON-Spalten für Blocks. +## Exceptions + +Das CMS Modul definiert folgende Domain-Exceptions: + +### Content Exceptions + +- **`ContentNotFoundException`** - Wird geworfen wenn Content nicht gefunden wird + ```php + throw ContentNotFoundException::forId($contentId); + throw ContentNotFoundException::forSlug($slug); + ``` + +- **`DuplicateSlugException`** - Wird geworfen wenn ein Slug bereits existiert + ```php + throw DuplicateSlugException::forSlug($slug); + ``` + +- **`InvalidContentStatusException`** - Wird geworfen bei ungültigen Status-Übergängen + ```php + throw InvalidContentStatusException::invalidTransition($currentStatus, $targetStatus); + ``` + +- **`InvalidBlockException`** - Wird geworfen bei ungültigen Block-Daten + ```php + throw InvalidBlockException::missingRequiredField($blockId, $field); + throw InvalidBlockException::invalidFieldType($blockId, $field, $expectedType); + ``` + +### ContentType Exceptions + +- **`ContentTypeNotFoundException`** - Wird geworfen wenn ContentType nicht gefunden wird + ```php + throw ContentTypeNotFoundException::forId($id); + ``` + +- **`DuplicateContentTypeSlugException`** - Wird geworfen wenn ein ContentType-Slug bereits existiert + ```php + throw DuplicateContentTypeSlugException::forSlug($slug); + ``` + +- **`CannotDeleteSystemContentTypeException`** - Wird geworfen beim Versuch, einen System-ContentType zu löschen + ```php + throw CannotDeleteSystemContentTypeException::forId($id); + ``` + +- **`CannotDeleteContentTypeInUseException`** - Wird geworfen beim Versuch, einen ContentType zu löschen, der noch von Content verwendet wird + ```php + throw CannotDeleteContentTypeInUseException::forId($id, $contentCount); + ``` + +## ContentTypeService::delete() - Schutz vor versehentlichem Löschen + +Die `ContentTypeService::delete()` Methode implementiert eine Schutzfunktion, die verhindert, dass ContentTypes gelöscht werden, die noch von Content-Instanzen verwendet werden: + +```php +public function delete(ContentTypeId $id): void +{ + $contentType = $this->contentTypeRepository->findById($id); + if ($contentType === null) { + throw ContentTypeNotFoundException::forId($id); + } + + // System ContentTypes können nicht gelöscht werden + if ($contentType->isSystem) { + throw CannotDeleteSystemContentTypeException::forId($id); + } + + // Prüfe ob ContentType noch von Content verwendet wird + $contents = $this->contentRepository->findByType($id); + if (count($contents) > 0) { + throw CannotDeleteContentTypeInUseException::forId($id, count($contents)); + } + + $this->contentTypeRepository->delete($id); +} +``` + +Dies verhindert Datenverlust und stellt sicher, dass keine verwaisten Content-Instanzen entstehen. + +## Tests + +Das CMS Modul verfügt über eine umfassende Test-Suite mit Pest Framework: + +### Test-Struktur + +``` +tests/ +├── Unit/ +│ └── Domain/ +│ └── Cms/ +│ ├── ValueObjects/ # Value Object Tests +│ ├── Services/ # Service Tests +│ ├── Repositories/ # Repository Tests +│ └── Rendering/ # Rendering Tests +├── Feature/ +│ └── Application/ +│ └── Cms/ # Controller Tests +└── Support/ + └── CmsTestHelpers.php # Test Helper Functions +``` + +### Test-Abdeckung + +- **Value Objects**: Alle Value Objects (ContentId, ContentSlug, BlockType, etc.) +- **Services**: ContentService, ContentTypeService, SlugGenerator, BlockValidator, ContentLocalizationService +- **Repositories**: DatabaseContentRepository, DatabaseContentTypeRepository, DatabaseContentTranslationRepository +- **Rendering**: BlockRendererRegistry, HeroBlockRenderer, TextBlockRenderer, ImageBlockRenderer, DefaultBlockRenderer, ContentRenderer +- **Controllers**: ContentsController, ContentTypesController + +### Tests ausführen + +```bash +# Alle CMS Tests +./vendor/bin/pest tests/Unit/Domain/Cms tests/Feature/Application/Cms + +# Spezifische Test-Datei +./vendor/bin/pest tests/Unit/Domain/Cms/Services/ContentServiceTest.php +``` + +### Test Helpers + +Die `CmsTestHelpers` Klasse bietet Factory-Methoden für Test-Daten: + +```php +use Tests\Support\CmsTestHelpers; + +$content = CmsTestHelpers::createContent($clock, [ + 'title' => 'Test Content', + 'blocks' => $blocks, + 'slug' => ContentSlug::fromString('test-slug') +]); + +$contentType = CmsTestHelpers::createContentType([ + 'name' => 'Test Type', + 'slug' => 'test_type' +]); +``` + diff --git a/src/Domain/Cms/Rendering/BlockRendererRegistry.php b/src/Domain/Cms/Rendering/BlockRendererRegistry.php index de0cf44d..484bbc98 100644 --- a/src/Domain/Cms/Rendering/BlockRendererRegistry.php +++ b/src/Domain/Cms/Rendering/BlockRendererRegistry.php @@ -4,13 +4,18 @@ declare(strict_types=1); namespace App\Domain\Cms\Rendering; -final readonly class BlockRendererRegistry +final class BlockRendererRegistry { /** * @var array */ private array $renderers; + public function __construct() + { + $this->renderers = []; + } + public function register(BlockRendererInterface $renderer): void { // Register renderer for all supported block types diff --git a/src/Domain/Cms/Rendering/HeroBlockRenderer.php b/src/Domain/Cms/Rendering/HeroBlockRenderer.php index 506b8c9f..0f6bc3a0 100644 --- a/src/Domain/Cms/Rendering/HeroBlockRenderer.php +++ b/src/Domain/Cms/Rendering/HeroBlockRenderer.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Domain\Cms\Rendering; +use App\Domain\Cms\ValueObjects\BlockType; use App\Domain\Cms\ValueObjects\ContentBlock; final readonly class HeroBlockRenderer implements BlockRendererInterface @@ -29,7 +30,7 @@ final readonly class HeroBlockRenderer implements BlockRendererInterface public function supports(string $blockType): bool { - return $blockType === \App\Domain\Cms\ValueObjects\BlockType::HERO; + return $blockType === BlockType::HERO; } } diff --git a/src/Domain/Cms/Rendering/ImageBlockRenderer.php b/src/Domain/Cms/Rendering/ImageBlockRenderer.php index 8ee7f212..f8537d4d 100644 --- a/src/Domain/Cms/Rendering/ImageBlockRenderer.php +++ b/src/Domain/Cms/Rendering/ImageBlockRenderer.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Domain\Cms\Rendering; +use App\Domain\Cms\ValueObjects\BlockType; use App\Domain\Cms\ValueObjects\ContentBlock; final readonly class ImageBlockRenderer implements BlockRendererInterface @@ -28,7 +29,7 @@ final readonly class ImageBlockRenderer implements BlockRendererInterface public function supports(string $blockType): bool { - return $blockType === \App\Domain\Cms\ValueObjects\BlockType::IMAGE; + return $blockType === BlockType::IMAGE; } } diff --git a/src/Domain/Cms/Rendering/TextBlockRenderer.php b/src/Domain/Cms/Rendering/TextBlockRenderer.php index e17f1756..1e12684e 100644 --- a/src/Domain/Cms/Rendering/TextBlockRenderer.php +++ b/src/Domain/Cms/Rendering/TextBlockRenderer.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Domain\Cms\Rendering; +use App\Domain\Cms\ValueObjects\BlockType; use App\Domain\Cms\ValueObjects\ContentBlock; final readonly class TextBlockRenderer implements BlockRendererInterface @@ -25,7 +26,7 @@ final readonly class TextBlockRenderer implements BlockRendererInterface public function supports(string $blockType): bool { - return $blockType === \App\Domain\Cms\ValueObjects\BlockType::TEXT; + return $blockType === BlockType::TEXT; } } diff --git a/src/Domain/Cms/Services/BlockTypeRegistry.php b/src/Domain/Cms/Services/BlockTypeRegistry.php index b1450db3..42bca481 100644 --- a/src/Domain/Cms/Services/BlockTypeRegistry.php +++ b/src/Domain/Cms/Services/BlockTypeRegistry.php @@ -14,11 +14,6 @@ final readonly class BlockTypeRegistry private array $registeredTypes; public function __construct() - { - $this->registerSystemTypes(); - } - - private function registerSystemTypes(): void { $systemTypes = [ BlockType::hero(), @@ -33,9 +28,12 @@ final readonly class BlockTypeRegistry BlockType::separator(), ]; + $registeredTypes = []; foreach ($systemTypes as $type) { - $this->registeredTypes[$type->toString()] = $type; + $registeredTypes[$type->toString()] = $type; } + + $this->registeredTypes = $registeredTypes; } public function register(BlockType $blockType): void @@ -44,7 +42,9 @@ final readonly class BlockTypeRegistry throw new \InvalidArgumentException("Block type '{$blockType->toString()}' is already registered"); } - $this->registeredTypes[$blockType->toString()] = $blockType; + // Cannot modify readonly property after construction + // This method is kept for API compatibility but will throw + throw new \RuntimeException('Cannot register new block types after construction. BlockTypeRegistry is immutable.'); } public function get(string $value): ?BlockType diff --git a/src/Domain/Cms/Services/ContentTypeService.php b/src/Domain/Cms/Services/ContentTypeService.php index 3816b980..9ecc5d2e 100644 --- a/src/Domain/Cms/Services/ContentTypeService.php +++ b/src/Domain/Cms/Services/ContentTypeService.php @@ -5,9 +5,11 @@ declare(strict_types=1); namespace App\Domain\Cms\Services; use App\Domain\Cms\Entities\ContentType; +use App\Domain\Cms\Exceptions\CannotDeleteContentTypeInUseException; use App\Domain\Cms\Exceptions\CannotDeleteSystemContentTypeException; use App\Domain\Cms\Exceptions\ContentTypeNotFoundException; use App\Domain\Cms\Exceptions\DuplicateContentTypeSlugException; +use App\Domain\Cms\Repositories\ContentRepository; use App\Domain\Cms\Repositories\ContentTypeRepository; use App\Domain\Cms\ValueObjects\ContentTypeId; use App\Framework\DateTime\Clock; @@ -16,6 +18,7 @@ final readonly class ContentTypeService { public function __construct( private ContentTypeRepository $contentTypeRepository, + private ContentRepository $contentRepository, private Clock $clock ) { } @@ -124,8 +127,11 @@ final readonly class ContentTypeService throw CannotDeleteSystemContentTypeException::forId($id); } - // TODO: Optional - Prüfe ob ContentType noch von Content verwendet wird - // $this->checkContentTypeInUse($id); + // Prüfe ob ContentType noch von Content verwendet wird + $contents = $this->contentRepository->findByType($id); + if (count($contents) > 0) { + throw CannotDeleteContentTypeInUseException::forId($id, count($contents)); + } $this->contentTypeRepository->delete($id); } diff --git a/tests/Feature/Application/Asset/AssetsControllerTest.php b/tests/Feature/Application/Asset/AssetsControllerTest.php new file mode 100644 index 00000000..447eb93b --- /dev/null +++ b/tests/Feature/Application/Asset/AssetsControllerTest.php @@ -0,0 +1,301 @@ +clock = new SystemClock(); + $this->assetService = Mockery::mock(AssetService::class); + $this->storage = Mockery::mock(AssetStorageInterface::class); + $this->jsonSerializer = new JsonSerializer(); + $this->controller = new AssetsController( + $this->assetService, + $this->storage, + $this->jsonSerializer + ); + }); + + it('uploads asset successfully', function () { + $asset = AssetTestHelpers::createAsset($this->clock); + + $this->assetService->shouldReceive('upload') + ->once() + ->andReturn($asset); + + $request = new HttpRequest( + method: Method::POST, + path: '/api/v1/assets/upload', + files: [ + 'file' => [ + 'tmp_name' => '/tmp/test.jpg', + 'type' => 'image/jpeg', + 'name' => 'test.jpg', + ], + ], + id: new RequestId('test') + ); + + // Mock is_uploaded_file + $uploadRequest = new \App\Application\Asset\Api\Requests\UploadAssetRequest(); + $response = $this->controller->upload($request, $uploadRequest); + + expect($response->status)->toBe(Status::CREATED); + }); + + it('returns 400 when no file provided', function () { + $request = new HttpRequest( + method: Method::POST, + path: '/api/v1/assets/upload', + files: [], + id: new RequestId('test') + ); + + $uploadRequest = new \App\Application\Asset\Api\Requests\UploadAssetRequest(); + $response = $this->controller->upload($request, $uploadRequest); + + expect($response->status)->toBe(Status::BAD_REQUEST); + }); + + it('shows asset by id', function () { + $assetId = AssetId::generate($this->clock); + $asset = AssetTestHelpers::createAsset($this->clock, id: $assetId); + $variants = []; + $tags = []; + + $this->assetService->shouldReceive('findById') + ->once() + ->with($assetId) + ->andReturn($asset); + + $this->assetService->shouldReceive('getVariants') + ->once() + ->with($assetId) + ->andReturn($variants); + + $this->assetService->shouldReceive('getTags') + ->once() + ->with($assetId) + ->andReturn($tags); + + $this->storage->shouldReceive('getUrl') + ->once() + ->andReturn('https://cdn.example.com/media/orig/test.jpg'); + + $response = $this->controller->show($assetId->toString()); + + expect($response->status)->toBe(Status::OK); + }); + + it('returns 404 when asset not found', function () { + $assetId = AssetId::generate($this->clock); + + $this->assetService->shouldReceive('findById') + ->once() + ->andThrow(\App\Domain\Asset\Exceptions\AssetNotFoundException::forId($assetId)); + + $response = $this->controller->show($assetId->toString()); + + expect($response->status)->toBe(Status::NOT_FOUND); + }); + + it('lists variants for asset', function () { + $assetId = AssetId::generate($this->clock); + $asset = AssetTestHelpers::createAsset($this->clock, id: $assetId); + $variants = [ + AssetTestHelpers::createAssetVariant($assetId), + ]; + + $this->assetService->shouldReceive('findById') + ->once() + ->with($assetId) + ->andReturn($asset); + + $this->assetService->shouldReceive('getVariants') + ->once() + ->with($assetId) + ->andReturn($variants); + + $this->storage->shouldReceive('getUrl') + ->once() + ->andReturn('https://cdn.example.com/variants/test.jpg'); + + $response = $this->controller->variants($assetId->toString()); + + expect($response->status)->toBe(Status::OK); + }); + + it('shows specific variant', function () { + $assetId = AssetId::generate($this->clock); + $asset = AssetTestHelpers::createAsset($this->clock, id: $assetId); + $variantName = VariantName::fromString('1200w.webp'); + $variant = AssetTestHelpers::createAssetVariant($assetId, $variantName); + + $this->assetService->shouldReceive('findById') + ->once() + ->with($assetId) + ->andReturn($asset); + + $this->assetService->shouldReceive('getVariant') + ->once() + ->with($assetId, $variantName) + ->andReturn($variant); + + $this->storage->shouldReceive('getUrl') + ->once() + ->andReturn('https://cdn.example.com/variants/test/1200w.webp'); + + $response = $this->controller->variant($assetId->toString(), '1200w.webp'); + + expect($response->status)->toBe(Status::OK); + }); + + it('returns 404 when variant not found', function () { + $assetId = AssetId::generate($this->clock); + $asset = AssetTestHelpers::createAsset($this->clock, id: $assetId); + $variantName = VariantName::fromString('1200w.webp'); + + $this->assetService->shouldReceive('findById') + ->once() + ->with($assetId) + ->andReturn($asset); + + $this->assetService->shouldReceive('getVariant') + ->once() + ->with($assetId, $variantName) + ->andReturn(null); + + $response = $this->controller->variant($assetId->toString(), '1200w.webp'); + + expect($response->status)->toBe(Status::NOT_FOUND); + }); + + it('gets asset URL', function () { + $assetId = AssetId::generate($this->clock); + $asset = AssetTestHelpers::createAsset($this->clock, id: $assetId); + $url = 'https://cdn.example.com/media/orig/test.jpg'; + + $this->assetService->shouldReceive('findById') + ->once() + ->with($assetId) + ->andReturn($asset); + + $this->storage->shouldReceive('getUrl') + ->once() + ->with($asset) + ->andReturn($url); + + $response = $this->controller->url($assetId->toString()); + + expect($response->status)->toBe(Status::OK); + }); + + it('gets signed URL for asset', function () { + $assetId = AssetId::generate($this->clock); + $asset = AssetTestHelpers::createAsset($this->clock, id: $assetId); + $signedUrl = 'https://cdn.example.com/media/orig/test.jpg?signature=abc123'; + + $this->assetService->shouldReceive('findById') + ->once() + ->with($assetId) + ->andReturn($asset); + + $this->storage->shouldReceive('getSignedUrl') + ->once() + ->with($asset, 3600) + ->andReturn($signedUrl); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/assets/{id}/signed-url', + query: ['expires' => '3600'], + id: new RequestId('test') + ); + + $response = $this->controller->signedUrl($request, $assetId->toString()); + + expect($response->status)->toBe(Status::OK); + }); + + it('deletes asset', function () { + $assetId = AssetId::generate($this->clock); + + $this->assetService->shouldReceive('delete') + ->once() + ->with($assetId) + ->andReturnNull(); + + $response = $this->controller->delete($assetId->toString()); + + expect($response->status)->toBe(Status::NO_CONTENT); + }); + + it('lists assets by tag', function () { + $tag = 'hero'; + $assets = [ + AssetTestHelpers::createAsset($this->clock), + ]; + + $this->assetService->shouldReceive('findByTag') + ->once() + ->with($tag) + ->andReturn($assets); + + $this->storage->shouldReceive('getUrl') + ->once() + ->andReturn('https://cdn.example.com/media/orig/test.jpg'); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/assets', + query: ['tag' => $tag], + id: new RequestId('test') + ); + + $response = $this->controller->index($request); + + expect($response->status)->toBe(Status::OK); + }); + + it('lists assets by bucket', function () { + $bucket = 'media'; + $assets = [ + AssetTestHelpers::createAsset($this->clock), + ]; + + $this->assetService->shouldReceive('findByBucket') + ->once() + ->with(Mockery::type(\App\Framework\Storage\ValueObjects\BucketName::class)) + ->andReturn($assets); + + $this->storage->shouldReceive('getUrl') + ->once() + ->andReturn('https://cdn.example.com/media/orig/test.jpg'); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/assets', + query: ['bucket' => $bucket], + id: new RequestId('test') + ); + + $response = $this->controller->index($request); + + expect($response->status)->toBe(Status::OK); + }); +}); + diff --git a/tests/Feature/Application/Cms/ContentTypesControllerTest.php b/tests/Feature/Application/Cms/ContentTypesControllerTest.php new file mode 100644 index 00000000..a9197b0e --- /dev/null +++ b/tests/Feature/Application/Cms/ContentTypesControllerTest.php @@ -0,0 +1,186 @@ +clock = new SystemClock(); + $this->contentTypeService = Mockery::mock(ContentTypeService::class); + $this->jsonSerializer = new JsonSerializer(); + $this->controller = new ContentTypesController( + $this->contentTypeService, + $this->jsonSerializer + ); + }); + + it('lists all content types', function () { + $contentTypes = [ + CmsTestHelpers::createContentType(), + ]; + + $this->contentTypeService->shouldReceive('findAll') + ->once() + ->andReturn($contentTypes); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/cms/content-types', + id: new RequestId('test') + ); + + $response = $this->controller->index($request); + + expect($response->status)->toBe(Status::OK); + }); + + it('shows content type by id', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(id: $id); + + $this->contentTypeService->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn($contentType); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/cms/content-types/{id}', + queryParams: ['id' => 'page'], + id: new RequestId('test') + ); + + $response = $this->controller->show($request); + + expect($response->status)->toBe(Status::OK); + }); + + it('returns 404 when content type not found', function () { + $id = ContentTypeId::fromString('missing'); + + $this->contentTypeService->shouldReceive('findById') + ->once() + ->andThrow(\App\Domain\Cms\Exceptions\ContentTypeNotFoundException::forId($id)); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/cms/content-types/{id}', + queryParams: ['id' => 'missing'], + id: new RequestId('test') + ); + + $response = $this->controller->show($request); + + expect($response->status)->toBe(Status::NOT_FOUND); + }); + + it('creates new content type', function () { + $contentType = CmsTestHelpers::createContentType(); + + $this->contentTypeService->shouldReceive('create') + ->once() + ->andReturn($contentType); + + $request = new HttpRequest( + method: Method::POST, + path: '/api/v1/cms/content-types', + body: json_encode([ + 'name' => 'Page', + 'slug' => 'page', + 'description' => 'A page content type', + ]), + id: new RequestId('test') + ); + + $createRequest = new \App\Application\Cms\Api\Requests\CreateContentTypeRequest(); + $response = $this->controller->create($request, $createRequest); + + expect($response->status)->toBe(Status::CREATED); + }); + + it('updates content type', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(id: $id); + $updatedContentType = $contentType->withName('Updated Page'); + + $this->contentTypeService->shouldReceive('update') + ->once() + ->andReturn($updatedContentType); + + $request = new HttpRequest( + method: Method::PUT, + path: '/api/v1/cms/content-types/{id}', + queryParams: ['id' => 'page'], + body: json_encode([ + 'name' => 'Updated Page', + ]), + id: new RequestId('test') + ); + + $updateRequest = new \App\Application\Cms\Api\Requests\UpdateContentTypeRequest(); + $response = $this->controller->update($request, $updateRequest); + + expect($response->status)->toBe(Status::OK); + }); + + it('deletes content type', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(id: $id, isSystem: false); + + $this->contentTypeService->shouldReceive('findById') + ->once() + ->andReturn($contentType); + + $this->contentTypeService->shouldReceive('delete') + ->once() + ->with($id) + ->andReturnNull(); + + $request = new HttpRequest( + method: Method::DELETE, + path: '/api/v1/cms/content-types/{id}', + queryParams: ['id' => 'page'], + id: new RequestId('test') + ); + + $response = $this->controller->delete($request); + + expect($response->status)->toBe(Status::NO_CONTENT); + }); + + it('returns 400 when trying to delete system content type', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(id: $id, isSystem: true); + + $this->contentTypeService->shouldReceive('findById') + ->once() + ->andReturn($contentType); + + $this->contentTypeService->shouldReceive('delete') + ->once() + ->andThrow(\App\Domain\Cms\Exceptions\CannotDeleteSystemContentTypeException::forId($id)); + + $request = new HttpRequest( + method: Method::DELETE, + path: '/api/v1/cms/content-types/{id}', + queryParams: ['id' => 'page'], + id: new RequestId('test') + ); + + $response = $this->controller->delete($request); + + expect($response->status)->toBe(Status::BAD_REQUEST); + }); +}); + diff --git a/tests/Feature/Application/Cms/ContentsControllerTest.php b/tests/Feature/Application/Cms/ContentsControllerTest.php new file mode 100644 index 00000000..238edffa --- /dev/null +++ b/tests/Feature/Application/Cms/ContentsControllerTest.php @@ -0,0 +1,247 @@ +clock = new SystemClock(); + $this->contentService = Mockery::mock(ContentService::class); + $this->jsonSerializer = new JsonSerializer(); + $this->controller = new ContentsController( + $this->contentService, + $this->jsonSerializer + ); + }); + + it('lists all contents', function () { + $contents = [ + CmsTestHelpers::createContent($this->clock), + ]; + + $this->contentService->shouldReceive('findPublished') + ->once() + ->andReturn($contents); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/cms/contents', + queryParams: ['status' => 'published'], + id: new RequestId('test') + ); + + $response = $this->controller->index($request); + + expect($response->status)->toBe(Status::OK); + expect($response->headers->get('Content-Type'))->toBe('application/json'); + }); + + it('lists contents by content type', function () { + $contentTypeId = ContentTypeId::fromString('page'); + $contents = [ + CmsTestHelpers::createContent($this->clock, contentTypeId: $contentTypeId), + ]; + + $this->contentService->shouldReceive('findByType') + ->once() + ->andReturn($contents); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/cms/contents', + queryParams: ['content_type_id' => 'page'], + id: new RequestId('test') + ); + + $response = $this->controller->index($request); + + expect($response->status)->toBe(Status::OK); + }); + + it('shows content by id', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + + $this->contentService->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($content); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/cms/contents/{id}', + queryParams: ['id' => $contentId->toString()], + id: new RequestId('test') + ); + + $response = $this->controller->show($request); + + expect($response->status)->toBe(Status::OK); + }); + + it('returns 404 when content not found', function () { + $contentId = ContentId::generate($this->clock); + + $this->contentService->shouldReceive('findById') + ->once() + ->andThrow(\App\Domain\Cms\Exceptions\ContentNotFoundException::forId($contentId)); + + $request = new HttpRequest( + method: Method::GET, + path: '/api/v1/cms/contents/{id}', + queryParams: ['id' => $contentId->toString()], + id: new RequestId('test') + ); + + $response = $this->controller->show($request); + + expect($response->status)->toBe(Status::NOT_FOUND); + }); + + it('creates new content', function () { + $content = CmsTestHelpers::createContent($this->clock); + + $this->contentService->shouldReceive('create') + ->once() + ->andReturn($content); + + $request = new HttpRequest( + method: Method::POST, + path: '/api/v1/cms/contents', + body: json_encode([ + 'content_type_id' => 'page', + 'title' => 'Test Page', + 'slug' => 'test-page', + 'blocks' => [ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero Title'], + ], + ], + ]), + id: new RequestId('test') + ); + + $createRequest = new \App\Application\Cms\Api\Requests\CreateContentRequest(); + $response = $this->controller->create($request, $createRequest); + + expect($response->status)->toBe(Status::CREATED); + }); + + it('updates content', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + $updatedContent = $content->withTitle('Updated Title'); + + $this->contentService->shouldReceive('findById') + ->once() + ->andReturn($content); + + $this->contentService->shouldReceive('updateTitle') + ->once() + ->andReturn($updatedContent); + + $request = new HttpRequest( + method: Method::PUT, + path: '/api/v1/cms/contents/{id}', + queryParams: ['id' => $contentId->toString()], + body: json_encode([ + 'title' => 'Updated Title', + ]), + id: new RequestId('test') + ); + + $updateRequest = new \App\Application\Cms\Api\Requests\UpdateContentRequest(); + $response = $this->controller->update($request, $updateRequest); + + expect($response->status)->toBe(Status::OK); + }); + + it('deletes content', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + + $this->contentService->shouldReceive('findById') + ->once() + ->andReturn($content); + + $this->contentService->shouldReceive('delete') + ->once() + ->with($contentId) + ->andReturnNull(); + + $request = new HttpRequest( + method: Method::DELETE, + path: '/api/v1/cms/contents/{id}', + queryParams: ['id' => $contentId->toString()], + id: new RequestId('test') + ); + + $response = $this->controller->delete($request); + + expect($response->status)->toBe(Status::NO_CONTENT); + }); + + it('publishes content', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + $publishedContent = $content->withStatus(ContentStatus::PUBLISHED); + + $this->contentService->shouldReceive('publish') + ->once() + ->with($contentId) + ->andReturn($publishedContent); + + $request = new HttpRequest( + method: Method::POST, + path: '/api/v1/cms/contents/{id}/publish', + queryParams: ['id' => $contentId->toString()], + id: new RequestId('test') + ); + + $response = $this->controller->publish($request); + + expect($response->status)->toBe(Status::OK); + }); + + it('unpublishes content', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId, status: ContentStatus::PUBLISHED); + $unpublishedContent = $content->withStatus(ContentStatus::DRAFT); + + $this->contentService->shouldReceive('unpublish') + ->once() + ->with($contentId) + ->andReturn($unpublishedContent); + + $request = new HttpRequest( + method: Method::POST, + path: '/api/v1/cms/contents/{id}/unpublish', + queryParams: ['id' => $contentId->toString()], + id: new RequestId('test') + ); + + $response = $this->controller->unpublish($request); + + expect($response->status)->toBe(Status::OK); + }); +}); + diff --git a/tests/Support/AssetTestHelpers.php b/tests/Support/AssetTestHelpers.php new file mode 100644 index 00000000..3d50bce5 --- /dev/null +++ b/tests/Support/AssetTestHelpers.php @@ -0,0 +1,92 @@ + 1200, 'height' => 675]) + ); + } + + public static function createAssetTag( + AssetId $assetId, + string $tag + ): AssetTag { + return new AssetTag( + assetId: $assetId, + tag: $tag + ); + } + + public static function createTestImageContent(): string + { + // Return a minimal valid JPEG header + return "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xFF\xD9"; + } + + public static function createAssetWithDimensions( + Clock $clock, + int $width, + int $height + ): Asset { + $meta = AssetMetadata::empty()->withDimensions($width, $height); + + return self::createAsset($clock, meta: $meta); + } +} + diff --git a/tests/Support/CmsTestHelpers.php b/tests/Support/CmsTestHelpers.php new file mode 100644 index 00000000..8761d0ff --- /dev/null +++ b/tests/Support/CmsTestHelpers.php @@ -0,0 +1,127 @@ + 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero Title'], + ], + ]); + } + + public static function createHeroBlock( + ?BlockId $blockId = null, + ?string $title = null + ): ContentBlock { + return ContentBlock::create( + type: BlockType::hero(), + blockId: $blockId ?? BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => $title ?? 'Hero Title']) + ); + } + + public static function createTextBlock( + ?BlockId $blockId = null, + ?string $content = null + ): ContentBlock { + return ContentBlock::create( + type: BlockType::text(), + blockId: $blockId ?? BlockId::fromString('text-1'), + data: BlockData::fromArray(['content' => $content ?? 'Text content']) + ); + } + + public static function createImageBlock( + ?BlockId $blockId = null, + ?string $imageId = null + ): ContentBlock { + return ContentBlock::create( + type: BlockType::image(), + blockId: $blockId ?? BlockId::fromString('image-1'), + data: BlockData::fromArray(['imageId' => $imageId ?? 'img-123']) + ); + } + + public static function createContentTranslation( + ContentId $contentId, + ?Locale $locale = null, + ?string $title = null, + ?ContentBlocks $blocks = null + ): \App\Domain\Cms\Entities\ContentTranslation { + return new \App\Domain\Cms\Entities\ContentTranslation( + contentId: $contentId, + locale: $locale ?? Locale::german(), + title: $title ?? 'Deutscher Titel', + blocks: $blocks ?? self::createSimpleBlocks(), + createdAt: Timestamp::now(), + updatedAt: Timestamp::now() + ); + } +} + diff --git a/tests/Unit/Domain/Asset/Pipeline/DerivatPipelineRegistryTest.php b/tests/Unit/Domain/Asset/Pipeline/DerivatPipelineRegistryTest.php new file mode 100644 index 00000000..1d3681c5 --- /dev/null +++ b/tests/Unit/Domain/Asset/Pipeline/DerivatPipelineRegistryTest.php @@ -0,0 +1,72 @@ +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); + }); + +}); + diff --git a/tests/Unit/Domain/Asset/Pipeline/ImageDerivatPipelineTest.php b/tests/Unit/Domain/Asset/Pipeline/ImageDerivatPipelineTest.php new file mode 100644 index 00000000..23da9750 --- /dev/null +++ b/tests/Unit/Domain/Asset/Pipeline/ImageDerivatPipelineTest.php @@ -0,0 +1,173 @@ +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; + } +}); + diff --git a/tests/Unit/Domain/Asset/Repositories/DatabaseAssetRepositoryTest.php b/tests/Unit/Domain/Asset/Repositories/DatabaseAssetRepositoryTest.php new file mode 100644 index 00000000..0bef882a --- /dev/null +++ b/tests/Unit/Domain/Asset/Repositories/DatabaseAssetRepositoryTest.php @@ -0,0 +1,126 @@ +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); + }); +}); + diff --git a/tests/Unit/Domain/Asset/Repositories/DatabaseAssetTagRepositoryTest.php b/tests/Unit/Domain/Asset/Repositories/DatabaseAssetTagRepositoryTest.php new file mode 100644 index 00000000..6e3c5d66 --- /dev/null +++ b/tests/Unit/Domain/Asset/Repositories/DatabaseAssetTagRepositoryTest.php @@ -0,0 +1,123 @@ +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); + }); +}); + diff --git a/tests/Unit/Domain/Asset/Repositories/DatabaseAssetVariantRepositoryTest.php b/tests/Unit/Domain/Asset/Repositories/DatabaseAssetVariantRepositoryTest.php new file mode 100644 index 00000000..6a6bb3f3 --- /dev/null +++ b/tests/Unit/Domain/Asset/Repositories/DatabaseAssetVariantRepositoryTest.php @@ -0,0 +1,98 @@ +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); + }); +}); + diff --git a/tests/Unit/Domain/Asset/Services/AssetServiceTest.php b/tests/Unit/Domain/Asset/Services/AssetServiceTest.php new file mode 100644 index 00000000..77e89b38 --- /dev/null +++ b/tests/Unit/Domain/Asset/Services/AssetServiceTest.php @@ -0,0 +1,337 @@ +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); + }); +}); + diff --git a/tests/Unit/Domain/Asset/Services/DeduplicationServiceTest.php b/tests/Unit/Domain/Asset/Services/DeduplicationServiceTest.php new file mode 100644 index 00000000..44fbe4f3 --- /dev/null +++ b/tests/Unit/Domain/Asset/Services/DeduplicationServiceTest.php @@ -0,0 +1,77 @@ +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(); + }); +}); + diff --git a/tests/Unit/Domain/Asset/Services/MetadataExtractorTest.php b/tests/Unit/Domain/Asset/Services/MetadataExtractorTest.php new file mode 100644 index 00000000..2eb8fa52 --- /dev/null +++ b/tests/Unit/Domain/Asset/Services/MetadataExtractorTest.php @@ -0,0 +1,86 @@ +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(); + }); +}); + diff --git a/tests/Unit/Domain/Asset/Storage/ObjectStorageAdapterTest.php b/tests/Unit/Domain/Asset/Storage/ObjectStorageAdapterTest.php new file mode 100644 index 00000000..b6024bb1 --- /dev/null +++ b/tests/Unit/Domain/Asset/Storage/ObjectStorageAdapterTest.php @@ -0,0 +1,138 @@ +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); + }); +}); + diff --git a/tests/Unit/Domain/Asset/ValueObjects/AssetIdTest.php b/tests/Unit/Domain/Asset/ValueObjects/AssetIdTest.php new file mode 100644 index 00000000..810c1e57 --- /dev/null +++ b/tests/Unit/Domain/Asset/ValueObjects/AssetIdTest.php @@ -0,0 +1,47 @@ +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(); + }); +}); + diff --git a/tests/Unit/Domain/Asset/ValueObjects/AssetMetadataTest.php b/tests/Unit/Domain/Asset/ValueObjects/AssetMetadataTest.php new file mode 100644 index 00000000..a114287c --- /dev/null +++ b/tests/Unit/Domain/Asset/ValueObjects/AssetMetadataTest.php @@ -0,0 +1,158 @@ +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(); + }); +}); + diff --git a/tests/Unit/Domain/Asset/ValueObjects/ObjectKeyGeneratorTest.php b/tests/Unit/Domain/Asset/ValueObjects/ObjectKeyGeneratorTest.php new file mode 100644 index 00000000..7ad62635 --- /dev/null +++ b/tests/Unit/Domain/Asset/ValueObjects/ObjectKeyGeneratorTest.php @@ -0,0 +1,92 @@ +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); + }); +}); + diff --git a/tests/Unit/Domain/Asset/ValueObjects/VariantNameTest.php b/tests/Unit/Domain/Asset/ValueObjects/VariantNameTest.php new file mode 100644 index 00000000..c16da41f --- /dev/null +++ b/tests/Unit/Domain/Asset/ValueObjects/VariantNameTest.php @@ -0,0 +1,81 @@ +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(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Rendering/BlockRendererRegistryTest.php b/tests/Unit/Domain/Cms/Rendering/BlockRendererRegistryTest.php new file mode 100644 index 00000000..61c9ba19 --- /dev/null +++ b/tests/Unit/Domain/Cms/Rendering/BlockRendererRegistryTest.php @@ -0,0 +1,76 @@ +registerForType('hero', $renderer); + + expect($registry->hasRenderer('hero'))->toBeTrue(); + expect($registry->getRenderer('hero'))->toBe($renderer); + }); + + it('can register multiple renderers', function () { + $registry = new BlockRendererRegistry(); + $heroRenderer = new HeroBlockRenderer(); + $textRenderer = new TextBlockRenderer(); + + $registry->registerForType('hero', $heroRenderer); + $registry->registerForType('text', $textRenderer); + + expect($registry->getRenderer('hero'))->toBe($heroRenderer); + expect($registry->getRenderer('text'))->toBe($textRenderer); + }); + + it('returns null for unregistered block type', function () { + $registry = new BlockRendererRegistry(); + + expect($registry->getRenderer('unknown'))->toBeNull(); + expect($registry->hasRenderer('unknown'))->toBeFalse(); + }); + + it('can find renderer by supports method', function () { + $registry = new BlockRendererRegistry(); + $heroRenderer = new HeroBlockRenderer(); + + $registry->register($heroRenderer); + + $found = $registry->getRenderer('hero'); + expect($found)->toBe($heroRenderer); + }); + + it('can get all registered renderers', function () { + $registry = new BlockRendererRegistry(); + $heroRenderer = new HeroBlockRenderer(); + $textRenderer = new TextBlockRenderer(); + + $registry->registerForType('hero', $heroRenderer); + $registry->registerForType('text', $textRenderer); + + $all = $registry->getAllRenderers(); + + expect($all)->toHaveCount(2); + expect($all['hero'])->toBe($heroRenderer); + expect($all['text'])->toBe($textRenderer); + }); + + it('overwrites renderer when registering same type twice', function () { + $registry = new BlockRendererRegistry(); + $renderer1 = new HeroBlockRenderer(); + $renderer2 = new HeroBlockRenderer(); + + $registry->registerForType('hero', $renderer1); + $registry->registerForType('hero', $renderer2); + + expect($registry->getRenderer('hero'))->toBe($renderer2); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Rendering/ContentRendererTest.php b/tests/Unit/Domain/Cms/Rendering/ContentRendererTest.php new file mode 100644 index 00000000..e21cb3ab --- /dev/null +++ b/tests/Unit/Domain/Cms/Rendering/ContentRendererTest.php @@ -0,0 +1,161 @@ +clock = new SystemClock(); + $this->blockRegistry = new BlockRendererRegistry(); + + // Create minimal real TemplateLoader for testing + $pathProvider = new PathProvider(__DIR__ . '/../../../../../'); + $nullCacheDriver = new NullCache(); + $serializer = Mockery::mock(Serializer::class); + $serializer->shouldReceive('serialize')->andReturnUsing(fn($data) => serialize($data)); + $serializer->shouldReceive('unserialize')->andReturnUsing(fn($data) => unserialize($data)); + $cache = new GeneralCache($nullCacheDriver, $serializer); + $templateLoader = new TemplateLoader( + pathProvider: $pathProvider, + cache: $cache, + discoveryRegistry: null, + templates: [], + templatePath: '/src/Framework/View/templates', + cacheEnabled: false + ); + + $componentCache = new ComponentCache('/tmp/test-cache'); + + // Create minimal real TemplateProcessor for testing + $container = new DefaultContainer(); + $templateProcessor = new TemplateProcessor( + astTransformers: [], + stringProcessors: [], + container: $container, + chainOptimizer: null, + compiledTemplateCache: null, + performanceTracker: null + ); + + // Create real FileStorage for testing + $fileStorage = new FileStorage( + basePath: sys_get_temp_dir() . '/test-components' + ); + + $this->componentRenderer = new ComponentRenderer( + $templateProcessor, + $componentCache, + $templateLoader, + $fileStorage + ); + + // Create real ContentLocalizationService for testing + $contentTranslationRepository = Mockery::mock(\App\Domain\Cms\Repositories\ContentTranslationRepository::class); + $this->localizationService = new ContentLocalizationService( + $contentTranslationRepository, + $this->clock + ); + $this->defaultRenderer = new DefaultBlockRenderer(); + $this->renderer = new ContentRenderer( + $this->blockRegistry, + $this->componentRenderer, + $this->localizationService, + $this->defaultRenderer + ); + }); + + it('renders content with multiple blocks', function () { + $content = CmsTestHelpers::createContent($this->clock); + // ContentLocalizationService is real instance, no need to mock + + $html = $this->renderer->render($content); + + expect($html)->toBeString(); + expect($html)->not->toBeEmpty(); + }); + + it('uses default renderer for unregistered block types', function () { + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'custom-1', + 'type' => 'custom-block', + 'data' => ['key' => 'value'], + ], + ]); + + $content = CmsTestHelpers::createContent($this->clock, blocks: $blocks); + // ContentLocalizationService is real instance, no need to mock + + $html = $this->renderer->render($content); + + expect($html)->toBeString(); + expect($html)->not->toBeEmpty(); + }); + + it('uses registered renderer for block type', function () { + $this->blockRegistry->registerForType('hero', new HeroBlockRenderer()); + $content = CmsTestHelpers::createContent($this->clock); + // ContentLocalizationService is real instance, no need to mock + + $html = $this->renderer->render($content); + + expect($html)->toBeString(); + expect($html)->not->toBeEmpty(); + }); + + it('renders blocks to component data array', function () { + $this->blockRegistry->registerForType('hero', new HeroBlockRenderer()); + $this->blockRegistry->registerForType('text', new TextBlockRenderer()); + $content = CmsTestHelpers::createContent($this->clock); + // ContentLocalizationService is real instance, no need to mock + + $componentData = $this->renderer->renderBlocksToComponentData($content); + + expect($componentData)->toBeArray(); + expect($componentData)->toHaveCount(2); + expect($componentData[0]['component'])->toBe('cms/hero'); + expect($componentData[1]['component'])->toBe('cms/text'); + }); + + it('uses provided locale for rendering', function () { + $content = CmsTestHelpers::createContent($this->clock); + $locale = Locale::german(); + // ContentLocalizationService is real instance, no need to mock + + $html = $this->renderer->render($content, $locale); + + expect($html)->toBeString(); + expect($html)->not->toBeEmpty(); + }); + +}); + diff --git a/tests/Unit/Domain/Cms/Rendering/DefaultBlockRendererTest.php b/tests/Unit/Domain/Cms/Rendering/DefaultBlockRendererTest.php new file mode 100644 index 00000000..3201e2ca --- /dev/null +++ b/tests/Unit/Domain/Cms/Rendering/DefaultBlockRendererTest.php @@ -0,0 +1,66 @@ + 'value']) + ); + + $result = $renderer->render($block); + + expect($result)->toHaveKeys(['component', 'data']); + expect($result['component'])->toBe('cms/block'); + expect($result['data']['blockType'])->toBe('custom-block'); + expect($result['data']['blockId'])->toBe('custom-1'); + expect($result['data']['data'])->toBe(['key' => 'value']); + }); + + it('renders block with settings', function () { + $renderer = new DefaultBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::fromString('custom-block'), + blockId: BlockId::fromString('custom-1'), + data: BlockData::fromArray(['key' => 'value']), + settings: BlockSettings::fromArray(['setting' => 'value']) + ); + + $result = $renderer->render($block); + + expect($result['data']['settings'])->toBe(['setting' => 'value']); + }); + + it('renders block without settings', function () { + $renderer = new DefaultBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::fromString('custom-block'), + blockId: BlockId::fromString('custom-1'), + data: BlockData::fromArray(['key' => 'value']) + ); + + $result = $renderer->render($block); + + expect($result['data']['settings'])->toBe([]); + }); + + it('supports all block types', function () { + $renderer = new DefaultBlockRenderer(); + + expect($renderer->supports('hero'))->toBeTrue(); + expect($renderer->supports('text'))->toBeTrue(); + expect($renderer->supports('custom-block'))->toBeTrue(); + expect($renderer->supports('any-type'))->toBeTrue(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Rendering/HeroBlockRendererTest.php b/tests/Unit/Domain/Cms/Rendering/HeroBlockRendererTest.php new file mode 100644 index 00000000..44cc9198 --- /dev/null +++ b/tests/Unit/Domain/Cms/Rendering/HeroBlockRendererTest.php @@ -0,0 +1,103 @@ + 'Hero Title']) + ); + + $result = $renderer->render($block); + + expect($result)->toHaveKeys(['component', 'data']); + expect($result['component'])->toBe('cms/hero'); + expect($result['data']['title'])->toBe('Hero Title'); + }); + + it('renders hero block with all fields', function () { + $renderer = new HeroBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray([ + 'title' => 'Hero Title', + 'subtitle' => 'Hero Subtitle', + 'backgroundImage' => 'img-123', + 'ctaText' => 'Click Me', + 'ctaLink' => '/signup', + ]), + settings: BlockSettings::fromArray([ + 'fullWidth' => true, + 'padding' => 'large', + ]) + ); + + $result = $renderer->render($block); + + expect($result['data']['title'])->toBe('Hero Title'); + expect($result['data']['subtitle'])->toBe('Hero Subtitle'); + expect($result['data']['backgroundImage'])->toBe('img-123'); + expect($result['data']['ctaText'])->toBe('Click Me'); + expect($result['data']['ctaLink'])->toBe('/signup'); + expect($result['data']['fullWidth'])->toBeTrue(); + expect($result['data']['padding'])->toBe('large'); + }); + + it('handles snake_case field names', function () { + $renderer = new HeroBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray([ + 'title' => 'Hero Title', + 'background_image' => 'img-123', + 'cta_text' => 'Click Me', + 'cta_link' => '/signup', + ]), + settings: BlockSettings::fromArray(['full_width' => true]) + ); + + $result = $renderer->render($block); + + expect($result['data']['backgroundImage'])->toBe('img-123'); + expect($result['data']['ctaText'])->toBe('Click Me'); + expect($result['data']['ctaLink'])->toBe('/signup'); + expect($result['data']['fullWidth'])->toBeTrue(); + }); + + it('provides default values for missing fields', function () { + $renderer = new HeroBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Hero Title']) + ); + + $result = $renderer->render($block); + + expect($result['data']['title'])->toBe('Hero Title'); + expect($result['data']['subtitle'])->toBeNull(); + expect($result['data']['fullWidth'])->toBeFalse(); + expect($result['data']['padding'])->toBe('medium'); + }); + + it('supports hero block type', function () { + $renderer = new HeroBlockRenderer(); + + expect($renderer->supports('hero'))->toBeTrue(); + expect($renderer->supports('text'))->toBeFalse(); + expect($renderer->supports('image'))->toBeFalse(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Rendering/ImageBlockRendererTest.php b/tests/Unit/Domain/Cms/Rendering/ImageBlockRendererTest.php new file mode 100644 index 00000000..323bcecb --- /dev/null +++ b/tests/Unit/Domain/Cms/Rendering/ImageBlockRendererTest.php @@ -0,0 +1,128 @@ + 'img-123']) + ); + + $result = $renderer->render($block); + + expect($result)->toHaveKeys(['component', 'data']); + expect($result['component'])->toBe('cms/image'); + expect($result['data']['imageId'])->toBe('img-123'); + }); + + it('renders image block with imageUrl', function () { + $renderer = new ImageBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray(['imageUrl' => 'https://example.com/image.jpg']) + ); + + $result = $renderer->render($block); + + expect($result['data']['imageUrl'])->toBe('https://example.com/image.jpg'); + }); + + it('handles snake_case field names', function () { + $renderer = new ImageBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray(['image_id' => 'img-123', 'image_url' => 'https://example.com/image.jpg']) + ); + + $result = $renderer->render($block); + + expect($result['data']['imageId'])->toBe('img-123'); + expect($result['data']['imageUrl'])->toBe('https://example.com/image.jpg'); + }); + + it('renders image block with caption and alt', function () { + $renderer = new ImageBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray([ + 'imageId' => 'img-123', + 'caption' => 'Image caption', + 'alt' => 'Alt text', + ]) + ); + + $result = $renderer->render($block); + + expect($result['data']['caption'])->toBe('Image caption'); + expect($result['data']['alt'])->toBe('Alt text'); + }); + + it('uses caption as alt when alt is missing', function () { + $renderer = new ImageBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray([ + 'imageId' => 'img-123', + 'caption' => 'Image caption', + ]) + ); + + $result = $renderer->render($block); + + expect($result['data']['alt'])->toBe('Image caption'); + }); + + it('renders image block with width and height settings', function () { + $renderer = new ImageBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray(['imageId' => 'img-123']), + settings: BlockSettings::fromArray(['width' => 800, 'height' => 600]) + ); + + $result = $renderer->render($block); + + expect($result['data']['width'])->toBe(800); + expect($result['data']['height'])->toBe(600); + }); + + it('provides default values for missing fields', function () { + $renderer = new ImageBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray(['imageId' => 'img-123']) + ); + + $result = $renderer->render($block); + + expect($result['data']['imageId'])->toBe('img-123'); + expect($result['data']['imageUrl'])->toBeNull(); + expect($result['data']['caption'])->toBeNull(); + expect($result['data']['alt'])->toBe(''); + }); + + it('supports image block type', function () { + $renderer = new ImageBlockRenderer(); + + expect($renderer->supports('image'))->toBeTrue(); + expect($renderer->supports('hero'))->toBeFalse(); + expect($renderer->supports('text'))->toBeFalse(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Rendering/TextBlockRendererTest.php b/tests/Unit/Domain/Cms/Rendering/TextBlockRendererTest.php new file mode 100644 index 00000000..d597cfcc --- /dev/null +++ b/tests/Unit/Domain/Cms/Rendering/TextBlockRendererTest.php @@ -0,0 +1,96 @@ + 'Text content']) + ); + + $result = $renderer->render($block); + + expect($result)->toHaveKeys(['component', 'data']); + expect($result['component'])->toBe('cms/text'); + expect($result['data']['content'])->toBe('Text content'); + }); + + it('renders text block with alignment', function () { + $renderer = new TextBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::text(), + blockId: BlockId::fromString('text-1'), + data: BlockData::fromArray([ + 'content' => 'Text content', + 'alignment' => 'center', + ]) + ); + + $result = $renderer->render($block); + + expect($result['data']['content'])->toBe('Text content'); + expect($result['data']['alignment'])->toBe('center'); + }); + + it('renders text block with maxWidth setting', function () { + $renderer = new TextBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::text(), + blockId: BlockId::fromString('text-1'), + data: BlockData::fromArray(['content' => 'Text content']), + settings: BlockSettings::fromArray(['maxWidth' => 800]) + ); + + $result = $renderer->render($block); + + expect($result['data']['maxWidth'])->toBe(800); + }); + + it('handles snake_case maxWidth setting', function () { + $renderer = new TextBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::text(), + blockId: BlockId::fromString('text-1'), + data: BlockData::fromArray(['content' => 'Text content']), + settings: BlockSettings::fromArray(['max_width' => 800]) + ); + + $result = $renderer->render($block); + + expect($result['data']['maxWidth'])->toBe(800); + }); + + it('provides default values for missing fields', function () { + $renderer = new TextBlockRenderer(); + $block = ContentBlock::create( + type: BlockType::text(), + blockId: BlockId::fromString('text-1'), + data: BlockData::fromArray(['content' => 'Text content']) + ); + + $result = $renderer->render($block); + + expect($result['data']['content'])->toBe('Text content'); + expect($result['data']['alignment'])->toBe('left'); + expect($result['data']['maxWidth'])->toBeNull(); + }); + + it('supports text block type', function () { + $renderer = new TextBlockRenderer(); + + expect($renderer->supports('text'))->toBeTrue(); + expect($renderer->supports('hero'))->toBeFalse(); + expect($renderer->supports('image'))->toBeFalse(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Repositories/DatabaseContentRepositoryTest.php b/tests/Unit/Domain/Cms/Repositories/DatabaseContentRepositoryTest.php new file mode 100644 index 00000000..545c6902 --- /dev/null +++ b/tests/Unit/Domain/Cms/Repositories/DatabaseContentRepositoryTest.php @@ -0,0 +1,172 @@ +connection = Mockery::mock(ConnectionInterface::class); + $this->repository = new DatabaseContentRepository($this->connection); + }); + + it('saves content to database', function () { + $content = CmsTestHelpers::createContent(new \App\Framework\DateTime\SystemClock()); + + $this->connection->shouldReceive('execute') + ->once() + ->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class)) + ->andReturn(1); + + $this->repository->save($content); + }); + + it('finds content by id', function () { + $contentId = ContentId::generate(new \App\Framework\DateTime\SystemClock()); + $row = [ + 'id' => $contentId->toString(), + 'content_type_id' => 'page', + 'slug' => 'test-page', + 'title' => 'Test Page', + 'blocks' => json_encode([['id' => 'hero-1', 'type' => 'hero', 'data' => ['title' => 'Hero']]]), + 'meta_data' => null, + 'status' => 'draft', + 'author_id' => null, + 'default_locale' => 'en', + 'published_at' => null, + 'created_at' => '2025-01-15 10:00:00', + 'updated_at' => '2025-01-15 10:00:00', + ]; + + $result = Mockery::mock(ResultInterface::class); + $result->shouldReceive('fetch') + ->once() + ->andReturn($row); + + $this->connection->shouldReceive('query') + ->once() + ->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class)) + ->andReturn($result); + + $found = $this->repository->findById($contentId); + + expect($found)->toBeInstanceOf(Content::class); + expect($found->id->equals($contentId))->toBeTrue(); + }); + + it('returns null when content not found by id', function () { + $contentId = ContentId::generate(new \App\Framework\DateTime\SystemClock()); + + $result = Mockery::mock(ResultInterface::class); + $result->shouldReceive('fetch') + ->once() + ->andReturn(null); + + $this->connection->shouldReceive('query') + ->once() + ->andReturn($result); + + $found = $this->repository->findById($contentId); + + expect($found)->toBeNull(); + }); + + it('finds content by slug', function () { + $slug = ContentSlug::fromString('test-page'); + $row = [ + 'id' => ContentId::generate(new \App\Framework\DateTime\SystemClock())->toString(), + 'content_type_id' => 'page', + 'slug' => 'test-page', + 'title' => 'Test Page', + 'blocks' => json_encode([['id' => 'hero-1', 'type' => 'hero', 'data' => ['title' => 'Hero']]]), + 'meta_data' => null, + 'status' => 'draft', + 'author_id' => null, + 'default_locale' => 'en', + 'published_at' => null, + 'created_at' => '2025-01-15 10:00:00', + 'updated_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->findBySlug($slug); + + expect($found)->toBeInstanceOf(Content::class); + expect($found->slug->equals($slug))->toBeTrue(); + }); + + it('checks if slug exists', function () { + $slug = ContentSlug::fromString('test-page'); + + $result = Mockery::mock(ResultInterface::class); + $result->shouldReceive('fetch') + ->once() + ->andReturn(['count' => 1]); + + $this->connection->shouldReceive('query') + ->once() + ->andReturn($result); + + expect($this->repository->existsSlug($slug))->toBeTrue(); + }); + + it('finds contents by type', function () { + $typeId = ContentTypeId::fromString('page'); + $row = [ + 'id' => ContentId::generate(new \App\Framework\DateTime\SystemClock())->toString(), + 'content_type_id' => 'page', + 'slug' => 'test-page', + 'title' => 'Test Page', + 'blocks' => json_encode([['id' => 'hero-1', 'type' => 'hero', 'data' => ['title' => 'Hero']]]), + 'meta_data' => null, + 'status' => 'draft', + 'author_id' => null, + 'default_locale' => 'en', + 'published_at' => null, + 'created_at' => '2025-01-15 10:00:00', + 'updated_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->findByType($typeId); + + expect($found)->toBeArray(); + expect($found)->toHaveCount(1); + }); + + it('deletes content', function () { + $contentId = ContentId::generate(new \App\Framework\DateTime\SystemClock()); + + $this->connection->shouldReceive('execute') + ->once() + ->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class)) + ->andReturn(1); + + $this->repository->delete($contentId); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Repositories/DatabaseContentTranslationRepositoryTest.php b/tests/Unit/Domain/Cms/Repositories/DatabaseContentTranslationRepositoryTest.php new file mode 100644 index 00000000..f146f108 --- /dev/null +++ b/tests/Unit/Domain/Cms/Repositories/DatabaseContentTranslationRepositoryTest.php @@ -0,0 +1,97 @@ +clock = new \App\Framework\DateTime\SystemClock(); + $this->connection = Mockery::mock(ConnectionInterface::class); + $this->repository = new DatabaseContentTranslationRepository($this->connection); + }); + + it('saves translation to database', function () { + $contentId = ContentId::generate($this->clock); + $translation = CmsTestHelpers::createContentTranslation($contentId); + + $this->connection->shouldReceive('execute') + ->once() + ->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class)) + ->andReturn(1); + + $this->repository->save($translation); + }); + + it('finds translation by content id and locale', function () { + $contentId = ContentId::generate($this->clock); + $locale = Locale::german(); + $row = [ + 'content_id' => $contentId->toString(), + 'locale' => 'de', + 'title' => 'Deutscher Titel', + 'blocks' => json_encode([['id' => 'hero-1', 'type' => 'hero', 'data' => ['title' => 'Hero']]]), + 'created_at' => '2025-01-15 10:00:00', + 'updated_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->findByContentAndLocale($contentId, $locale); + + expect($found)->toBeInstanceOf(ContentTranslation::class); + expect($found->locale->equals($locale))->toBeTrue(); + }); + + it('finds all translations for content', function () { + $contentId = ContentId::generate($this->clock); + $row = [ + 'content_id' => $contentId->toString(), + 'locale' => 'de', + 'title' => 'Deutscher Titel', + 'blocks' => json_encode([['id' => 'hero-1', 'type' => 'hero', 'data' => ['title' => 'Hero']]]), + 'created_at' => '2025-01-15 10:00:00', + 'updated_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->findByContent($contentId); + + expect($found)->toBeArray(); + expect($found)->toHaveCount(1); + }); + + it('deletes translation', function () { + $contentId = ContentId::generate($this->clock); + $locale = Locale::german(); + + $this->connection->shouldReceive('execute') + ->once() + ->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class)) + ->andReturn(1); + + $this->repository->delete($contentId, $locale); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Repositories/DatabaseContentTypeRepositoryTest.php b/tests/Unit/Domain/Cms/Repositories/DatabaseContentTypeRepositoryTest.php new file mode 100644 index 00000000..c4bf625c --- /dev/null +++ b/tests/Unit/Domain/Cms/Repositories/DatabaseContentTypeRepositoryTest.php @@ -0,0 +1,135 @@ +connection = Mockery::mock(ConnectionInterface::class); + $this->repository = new DatabaseContentTypeRepository($this->connection); + }); + + it('saves content type to database', function () { + $contentType = CmsTestHelpers::createContentType(); + + $this->connection->shouldReceive('execute') + ->once() + ->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class)) + ->andReturn(1); + + $this->repository->save($contentType); + }); + + it('finds content type by id', function () { + $id = ContentTypeId::fromString('page'); + $row = [ + 'id' => 'page', + 'name' => 'Page', + 'slug' => 'page', + 'description' => 'A page content type', + 'is_system' => 0, + 'created_at' => '2025-01-15 10:00:00', + 'updated_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($id); + + expect($found)->toBeInstanceOf(ContentType::class); + expect($found->id->equals($id))->toBeTrue(); + }); + + it('finds content type by slug', function () { + $slug = 'page'; + $row = [ + 'id' => 'page', + 'name' => 'Page', + 'slug' => 'page', + 'description' => 'A page content type', + 'is_system' => 0, + 'created_at' => '2025-01-15 10:00:00', + 'updated_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->findBySlug($slug); + + expect($found)->toBeInstanceOf(ContentType::class); + expect($found->slug)->toBe('page'); + }); + + it('finds all content types', function () { + $row = [ + 'id' => 'page', + 'name' => 'Page', + 'slug' => 'page', + 'description' => 'A page content type', + 'is_system' => 0, + 'created_at' => '2025-01-15 10:00:00', + 'updated_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->findAll(); + + expect($found)->toBeArray(); + expect($found)->toHaveCount(1); + }); + + it('checks if content type exists', function () { + $id = ContentTypeId::fromString('page'); + + $result = Mockery::mock(ResultInterface::class); + $result->shouldReceive('fetch') + ->once() + ->andReturn(['count' => 1]); + + $this->connection->shouldReceive('query') + ->once() + ->andReturn($result); + + expect($this->repository->exists($id))->toBeTrue(); + }); + + it('deletes content type', function () { + $id = ContentTypeId::fromString('page'); + + $this->connection->shouldReceive('execute') + ->once() + ->with(Mockery::type(\App\Framework\Database\ValueObjects\SqlQuery::class)) + ->andReturn(1); + + $this->repository->delete($id); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Services/BlockValidatorTest.php b/tests/Unit/Domain/Cms/Services/BlockValidatorTest.php new file mode 100644 index 00000000..17c0dbad --- /dev/null +++ b/tests/Unit/Domain/Cms/Services/BlockValidatorTest.php @@ -0,0 +1,169 @@ +registry = new BlockTypeRegistry(); + $this->validator = new BlockValidator($this->registry); + }); + + it('validates hero block with required title', function () { + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Hero Title']) + ); + + $this->validator->validate($block); + expect(true)->toBeTrue(); // Test passes if no exception is thrown + }); + + it('throws exception for hero block without title', function () { + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray([]) + ); + + expect(fn () => $this->validator->validate($block)) + ->toThrow(InvalidBlockException::class); + }); + + it('validates text block with required content', function () { + $block = ContentBlock::create( + type: BlockType::text(), + blockId: BlockId::fromString('text-1'), + data: BlockData::fromArray(['content' => 'Text content']) + ); + + $this->validator->validate($block); + expect(true)->toBeTrue(); // Test passes if no exception is thrown + }); + + it('throws exception for text block without content', function () { + $block = ContentBlock::create( + type: BlockType::text(), + blockId: BlockId::fromString('text-1'), + data: BlockData::fromArray([]) + ); + + expect(fn () => $this->validator->validate($block)) + ->toThrow(InvalidBlockException::class); + }); + + it('validates image block with imageId', function () { + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray(['imageId' => 'img-123']) + ); + + $this->validator->validate($block); + expect(true)->toBeTrue(); // Test passes if no exception is thrown + }); + + it('validates image block with image_url', function () { + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray(['image_url' => 'https://example.com/image.jpg']) + ); + + $this->validator->validate($block); + expect(true)->toBeTrue(); // Test passes if no exception is thrown + }); + + it('throws exception for image block without imageId or image_url', function () { + $block = ContentBlock::create( + type: BlockType::image(), + blockId: BlockId::fromString('image-1'), + data: BlockData::fromArray([]) + ); + + expect(fn () => $this->validator->validate($block)) + ->toThrow(InvalidBlockException::class); + }); + + it('validates gallery block with images array', function () { + $block = ContentBlock::create( + type: BlockType::gallery(), + blockId: BlockId::fromString('gallery-1'), + data: BlockData::fromArray(['images' => ['img1', 'img2']]) + ); + + $this->validator->validate($block); + expect(true)->toBeTrue(); // Test passes if no exception is thrown + }); + + it('throws exception for gallery block without images', function () { + $block = ContentBlock::create( + type: BlockType::gallery(), + blockId: BlockId::fromString('gallery-1'), + data: BlockData::fromArray([]) + ); + + expect(fn () => $this->validator->validate($block)) + ->toThrow(InvalidBlockException::class); + }); + + it('validates cta block with buttonText and buttonLink', function () { + $block = ContentBlock::create( + type: BlockType::cta(), + blockId: BlockId::fromString('cta-1'), + data: BlockData::fromArray([ + 'buttonText' => 'Click Me', + 'buttonLink' => '/signup', + ]) + ); + + $this->validator->validate($block); + expect(true)->toBeTrue(); // Test passes if no exception is thrown + }); + + it('validates cta block with button_text and button_link', function () { + $block = ContentBlock::create( + type: BlockType::cta(), + blockId: BlockId::fromString('cta-1'), + data: BlockData::fromArray([ + 'button_text' => 'Click Me', + 'button_link' => '/signup', + ]) + ); + + $this->validator->validate($block); + expect(true)->toBeTrue(); // Test passes if no exception is thrown + }); + + it('validates separator block without required data', function () { + $block = ContentBlock::create( + type: BlockType::separator(), + blockId: BlockId::fromString('sep-1'), + data: BlockData::empty() + ); + + $this->validator->validate($block); + expect(true)->toBeTrue(); // Test passes if no exception is thrown + }); + + it('throws exception for unregistered block type', function () { + $customType = BlockType::fromString('custom-block', false); + $block = ContentBlock::create( + type: $customType, + blockId: BlockId::fromString('custom-1'), + data: BlockData::empty() + ); + + expect(fn () => $this->validator->validate($block)) + ->toThrow(InvalidArgumentException::class, 'not registered'); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Services/ContentLocalizationServiceTest.php b/tests/Unit/Domain/Cms/Services/ContentLocalizationServiceTest.php new file mode 100644 index 00000000..875b425e --- /dev/null +++ b/tests/Unit/Domain/Cms/Services/ContentLocalizationServiceTest.php @@ -0,0 +1,173 @@ +clock = new SystemClock(); + $this->repository = Mockery::mock(ContentTranslationRepository::class); + $this->service = new ContentLocalizationService($this->repository, $this->clock); + }); + + it('creates translation', function () { + $contentId = ContentId::generate($this->clock); + $locale = Locale::german(); + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'text-1', + 'type' => 'text', + 'data' => ['content' => 'Deutscher Text'], + ], + ]); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $translation = $this->service->createTranslation( + contentId: $contentId, + locale: $locale, + title: 'Deutscher Titel', + blocks: $blocks + ); + + expect($translation)->toBeInstanceOf(ContentTranslation::class); + expect($translation->locale->equals($locale))->toBeTrue(); + expect($translation->title)->toBe('Deutscher Titel'); + }); + + it('updates translation', function () { + $contentId = ContentId::generate($this->clock); + $locale = Locale::german(); + $translation = CmsTestHelpers::createContentTranslation($contentId, $locale); + + $this->repository->shouldReceive('findByContentAndLocale') + ->once() + ->with($contentId, $locale) + ->andReturn($translation); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $updated = $this->service->updateTranslation( + contentId: $contentId, + locale: $locale, + title: 'Aktualisierter Titel' + ); + + expect($updated->title)->toBe('Aktualisierter Titel'); + }); + + it('throws exception when updating non-existent translation', function () { + $contentId = ContentId::generate($this->clock); + $locale = Locale::german(); + + $this->repository->shouldReceive('findByContentAndLocale') + ->once() + ->with($contentId, $locale) + ->andReturn(null); + + expect(fn () => $this->service->updateTranslation( + contentId: $contentId, + locale: $locale, + title: 'New Title' + ))->toThrow(DomainException::class); + }); + + it('gets translation', function () { + $contentId = ContentId::generate($this->clock); + $locale = Locale::german(); + $translation = CmsTestHelpers::createContentTranslation($contentId, $locale); + + $this->repository->shouldReceive('findByContentAndLocale') + ->once() + ->with($contentId, $locale) + ->andReturn($translation); + + $found = $this->service->getTranslation($contentId, $locale); + + expect($found)->toBe($translation); + }); + + it('returns content for default locale', function () { + $content = CmsTestHelpers::createContent($this->clock); + $locale = $content->defaultLocale; + + $localized = $this->service->getContentForLocale($content, $locale); + + expect($localized)->toBe($content); + }); + + it('returns translated content for non-default locale', function () { + $content = CmsTestHelpers::createContent($this->clock); + $locale = Locale::german(); + $translation = CmsTestHelpers::createContentTranslation($content->id, $locale); + + $this->repository->shouldReceive('findByContentAndLocale') + ->once() + ->with($content->id, $locale) + ->andReturn($translation); + + $localized = $this->service->getContentForLocale($content, $locale); + + expect($localized->title)->toBe($translation->title); + }); + + it('falls back to default locale when translation not found', function () { + $content = CmsTestHelpers::createContent($this->clock); + $locale = Locale::german(); + + $this->repository->shouldReceive('findByContentAndLocale') + ->once() + ->with($content->id, $locale) + ->andReturn(null); + + $localized = $this->service->getContentForLocale($content, $locale); + + expect($localized->title)->toBe($content->title); + }); + + it('gets all translations for content', function () { + $contentId = ContentId::generate($this->clock); + $translations = [ + CmsTestHelpers::createContentTranslation($contentId, Locale::german()), + ]; + + $this->repository->shouldReceive('findByContent') + ->once() + ->with($contentId) + ->andReturn($translations); + + $found = $this->service->getAllTranslations($contentId); + + expect($found)->toBe($translations); + }); + + it('deletes translation', function () { + $contentId = ContentId::generate($this->clock); + $locale = Locale::german(); + + $this->repository->shouldReceive('delete') + ->once() + ->with($contentId, $locale) + ->andReturnNull(); + + $this->service->deleteTranslation($contentId, $locale); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Services/ContentServiceTest.php b/tests/Unit/Domain/Cms/Services/ContentServiceTest.php new file mode 100644 index 00000000..bca51276 --- /dev/null +++ b/tests/Unit/Domain/Cms/Services/ContentServiceTest.php @@ -0,0 +1,319 @@ +clock = new SystemClock(); + $this->repository = Mockery::mock(ContentRepository::class); + $this->blockTypeRegistry = new BlockTypeRegistry(); + $this->blockValidator = new BlockValidator($this->blockTypeRegistry); + // Use real SlugGenerator instance with mocked repository + $this->slugGenerator = new SlugGenerator($this->repository); + $this->service = new ContentService( + $this->repository, + $this->blockValidator, + $this->slugGenerator, + $this->clock + ); + }); + + it('creates content with provided slug', function () { + $contentTypeId = ContentTypeId::fromString('page'); + $slug = ContentSlug::fromString('my-page'); + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero Title'], + ], + ]); + + $this->repository->shouldReceive('existsSlug') + ->once() + ->with($slug) + ->andReturn(false); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $content = $this->service->create( + contentTypeId: $contentTypeId, + title: 'My Page', + blocks: $blocks, + defaultLocale: Locale::english(), + slug: $slug + ); + + expect($content)->toBeInstanceOf(Content::class); + expect($content->slug->equals($slug))->toBeTrue(); + expect($content->title)->toBe('My Page'); + }); + + it('generates slug when not provided', function () { + $contentTypeId = ContentTypeId::fromString('page'); + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero Title'], + ], + ]); + + // SlugGenerator will generate 'my-page' from 'My Page' + // Use Mockery::on() to match ContentSlug objects by their string value + $this->repository->shouldReceive('existsSlug') + ->atLeast()->once() + ->with(Mockery::on(function ($slug) { + return $slug instanceof ContentSlug && $slug->toString() === 'my-page'; + })) + ->andReturn(false); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $content = $this->service->create( + contentTypeId: $contentTypeId, + title: 'My Page', + blocks: $blocks, + defaultLocale: Locale::english() + ); + + expect($content->slug->toString())->toBe('my-page'); + }); + + it('throws exception when slug already exists', function () { + $contentTypeId = ContentTypeId::fromString('page'); + $slug = ContentSlug::fromString('existing-slug'); + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero Title'], + ], + ]); + + $this->repository->shouldReceive('existsSlug') + ->once() + ->with($slug) + ->andReturn(true); + + expect(fn () => $this->service->create( + contentTypeId: $contentTypeId, + title: 'My Page', + blocks: $blocks, + defaultLocale: Locale::english(), + slug: $slug + ))->toThrow(DuplicateSlugException::class); + }); + + it('finds content by id', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($content); + + $found = $this->service->findById($contentId); + + expect($found)->toBe($content); + }); + + it('throws exception when content not found by id', function () { + $contentId = ContentId::generate($this->clock); + + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn(null); + + expect(fn () => $this->service->findById($contentId)) + ->toThrow(ContentNotFoundException::class); + }); + + it('finds content by slug', function () { + $slug = ContentSlug::fromString('my-page'); + $content = CmsTestHelpers::createContent($this->clock); + + $this->repository->shouldReceive('findBySlug') + ->once() + ->with($slug) + ->andReturn($content); + + $found = $this->service->findBySlug($slug); + + expect($found)->toBe($content); + }); + + it('updates content title', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($content); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $updated = $this->service->updateTitle($contentId, 'New Title'); + + expect($updated->title)->toBe('New Title'); + }); + + it('updates content slug', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + $newSlug = ContentSlug::fromString('new-slug'); + + // updateSlug calls findById once (line 107) and findBySlug once (line 106) + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($content); + + $this->repository->shouldReceive('findBySlug') + ->once() + ->with($newSlug) + ->andReturn(null); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $updated = $this->service->updateSlug($contentId, $newSlug); + + expect($updated->slug->equals($newSlug))->toBeTrue(); + }); + + it('publishes content', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($content); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $published = $this->service->publish($contentId); + + expect($published->status)->toBe(ContentStatus::PUBLISHED); + }); + + it('unpublishes content', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + $publishedContent = $content->withStatus(ContentStatus::PUBLISHED); + + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($publishedContent); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $unpublished = $this->service->unpublish($contentId); + + expect($unpublished->status)->toBe(ContentStatus::DRAFT); + }); + + it('deletes content', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($content); + + $this->repository->shouldReceive('delete') + ->once() + ->with($contentId) + ->andReturnNull(); + + $this->service->delete($contentId); + }); + + it('updates content blocks', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + $newBlocks = ContentBlocks::fromArray([ + [ + 'id' => 'text-1', + 'type' => 'text', + 'data' => ['content' => 'New content'], + ], + ]); + + // BlockValidator is real instance, no need to mock + + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($content); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $updated = $this->service->updateBlocks($contentId, $newBlocks); + + expect($updated->blocks->count())->toBe(1); + }); + + it('updates content metadata', function () { + $contentId = ContentId::generate($this->clock); + $content = CmsTestHelpers::createContent($this->clock, id: $contentId); + $metaData = BlockData::fromArray(['seo_title' => 'SEO Title']); + + $this->repository->shouldReceive('findById') + ->once() + ->with($contentId) + ->andReturn($content); + + $this->repository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $updated = $this->service->updateMetaData($contentId, $metaData); + + expect($updated->metaData)->not->toBeNull(); + expect($updated->metaData->get('seo_title'))->toBe('SEO Title'); + }); +}); + diff --git a/tests/Unit/Domain/Cms/Services/ContentTypeServiceTest.php b/tests/Unit/Domain/Cms/Services/ContentTypeServiceTest.php new file mode 100644 index 00000000..0279f754 --- /dev/null +++ b/tests/Unit/Domain/Cms/Services/ContentTypeServiceTest.php @@ -0,0 +1,223 @@ +clock = new SystemClock(); + $this->contentTypeRepository = Mockery::mock(ContentTypeRepository::class); + $this->contentRepository = Mockery::mock(ContentRepository::class); + $this->service = new ContentTypeService($this->contentTypeRepository, $this->contentRepository, $this->clock); + }); + + it('creates content type', function () { + $this->contentTypeRepository->shouldReceive('findBySlug') + ->once() + ->with('page') + ->andReturn(null); + + $this->contentTypeRepository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $contentType = $this->service->create( + name: 'Page', + slug: 'page', + description: 'A page content type' + ); + + expect($contentType)->toBeInstanceOf(ContentType::class); + expect($contentType->name)->toBe('Page'); + expect($contentType->slug)->toBe('page'); + }); + + it('throws exception when slug already exists', function () { + $existing = CmsTestHelpers::createContentType(); + + $this->contentTypeRepository->shouldReceive('findBySlug') + ->once() + ->with('page') + ->andReturn($existing); + + expect(fn () => $this->service->create( + name: 'Page', + slug: 'page' + ))->toThrow(DuplicateContentTypeSlugException::class); + }); + + it('finds content type by id', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(); + + $this->contentTypeRepository->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn($contentType); + + $found = $this->service->findById($id); + + expect($found)->toBe($contentType); + }); + + it('throws exception when content type not found by id', function () { + $id = ContentTypeId::fromString('missing'); + + $this->contentTypeRepository->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn(null); + + expect(fn () => $this->service->findById($id)) + ->toThrow(ContentTypeNotFoundException::class); + }); + + it('finds content type by slug', function () { + $contentType = CmsTestHelpers::createContentType(); + + $this->contentTypeRepository->shouldReceive('findBySlug') + ->once() + ->with('page') + ->andReturn($contentType); + + $found = $this->service->findBySlug('page'); + + expect($found)->toBe($contentType); + }); + + it('updates content type name', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(); + + $this->contentTypeRepository->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn($contentType); + + $this->contentTypeRepository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $updated = $this->service->update($id, name: 'Updated Page'); + + expect($updated->name)->toBe('Updated Page'); + }); + + it('updates content type description', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(); + + $this->contentTypeRepository->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn($contentType); + + $this->contentTypeRepository->shouldReceive('save') + ->once() + ->andReturnNull(); + + $updated = $this->service->update($id, description: 'Updated description'); + + expect($updated->description)->toBe('Updated description'); + }); + + it('throws exception when trying to change slug', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(); + + $this->contentTypeRepository->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn($contentType); + + // update() checks findBySlug if slug is different + $this->contentTypeRepository->shouldReceive('findBySlug') + ->once() + ->with('new-slug') + ->andReturn(null); + + expect(fn () => $this->service->update($id, slug: 'new-slug')) + ->toThrow(\InvalidArgumentException::class, 'slug cannot be changed'); + }); + + it('deletes non-system content type', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(isSystem: false); + + $this->contentTypeRepository->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn($contentType); + + $this->contentRepository->shouldReceive('findByType') + ->once() + ->with($id) + ->andReturn([]); + + $this->contentTypeRepository->shouldReceive('delete') + ->once() + ->with($id) + ->andReturnNull(); + + $this->service->delete($id); + }); + + it('throws exception when trying to delete system content type', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(isSystem: true); + + $this->contentTypeRepository->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn($contentType); + + expect(fn () => $this->service->delete($id)) + ->toThrow(CannotDeleteSystemContentTypeException::class); + }); + + it('throws exception when trying to delete content type in use', function () { + $id = ContentTypeId::fromString('page'); + $contentType = CmsTestHelpers::createContentType(isSystem: false); + $contents = [CmsTestHelpers::createContent($this->clock)]; + + $this->contentTypeRepository->shouldReceive('findById') + ->once() + ->with($id) + ->andReturn($contentType); + + $this->contentRepository->shouldReceive('findByType') + ->once() + ->with($id) + ->andReturn($contents); + + expect(fn () => $this->service->delete($id)) + ->toThrow(\App\Domain\Cms\Exceptions\CannotDeleteContentTypeInUseException::class); + }); + + it('finds all content types', function () { + $contentTypes = [ + CmsTestHelpers::createContentType(), + ]; + + $this->contentTypeRepository->shouldReceive('findAll') + ->once() + ->andReturn($contentTypes); + + $found = $this->service->findAll(); + + expect($found)->toBe($contentTypes); + }); + +}); + diff --git a/tests/Unit/Domain/Cms/Services/SlugGeneratorTest.php b/tests/Unit/Domain/Cms/Services/SlugGeneratorTest.php new file mode 100644 index 00000000..009756ea --- /dev/null +++ b/tests/Unit/Domain/Cms/Services/SlugGeneratorTest.php @@ -0,0 +1,89 @@ +repository = Mockery::mock(ContentRepository::class); + $this->slugGenerator = new SlugGenerator($this->repository); + }); + + it('generates slug from title', function () { + $slug = $this->slugGenerator->generateFromTitle('My Awesome Page'); + + expect($slug)->toBeInstanceOf(ContentSlug::class); + expect($slug->toString())->toBe('my-awesome-page'); + }); + + it('handles special characters in title', function () { + $slug = $this->slugGenerator->generateFromTitle('Hello & World!'); + + expect($slug->toString())->toBe('hello-world'); + }); + + it('handles empty title', function () { + $slug = $this->slugGenerator->generateFromTitle(''); + + expect($slug->toString())->toStartWith('content-'); + }); + + it('truncates long titles', function () { + $longTitle = str_repeat('a', 300); + $slug = $this->slugGenerator->generateFromTitle($longTitle); + + expect(strlen($slug->toString()))->toBeLessThanOrEqual(255); + }); + + it('generates unique slug when base slug exists', function () { + $this->repository->shouldReceive('existsSlug') + ->once() + ->with(Mockery::on(fn ($slug) => $slug->toString() === 'my-page')) + ->andReturn(true); + + $this->repository->shouldReceive('existsSlug') + ->once() + ->with(Mockery::on(fn ($slug) => $slug->toString() === 'my-page-1')) + ->andReturn(false); + + $slug = $this->slugGenerator->generateUniqueFromTitle('My Page'); + + expect($slug->toString())->toBe('my-page-1'); + }); + + it('generates unique slug with multiple collisions', function () { + $this->repository->shouldReceive('existsSlug') + ->once() + ->with(Mockery::on(fn ($slug) => $slug->toString() === 'my-page')) + ->andReturn(true); + + $this->repository->shouldReceive('existsSlug') + ->once() + ->with(Mockery::on(fn ($slug) => $slug->toString() === 'my-page-1')) + ->andReturn(true); + + $this->repository->shouldReceive('existsSlug') + ->once() + ->with(Mockery::on(fn ($slug) => $slug->toString() === 'my-page-2')) + ->andReturn(false); + + $slug = $this->slugGenerator->generateUniqueFromTitle('My Page'); + + expect($slug->toString())->toBe('my-page-2'); + }); + + it('returns base slug when it does not exist', function () { + $this->repository->shouldReceive('existsSlug') + ->once() + ->with(Mockery::on(fn ($slug) => $slug->toString() === 'my-page')) + ->andReturn(false); + + $slug = $this->slugGenerator->generateUniqueFromTitle('My Page'); + + expect($slug->toString())->toBe('my-page'); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/BlockDataTest.php b/tests/Unit/Domain/Cms/ValueObjects/BlockDataTest.php new file mode 100644 index 00000000..63947e4c --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/BlockDataTest.php @@ -0,0 +1,110 @@ + 'Test', 'content' => 'Hello']); + + expect($data->toArray())->toBe(['title' => 'Test', 'content' => 'Hello']); + }); + + it('can be created as empty', function () { + $data = BlockData::empty(); + + expect($data->toArray())->toBe([]); + expect($data->has('key'))->toBeFalse(); + }); + + it('can get values by key', function () { + $data = BlockData::fromArray(['title' => 'Test', 'count' => 5]); + + expect($data->get('title'))->toBe('Test'); + expect($data->get('count'))->toBe(5); + expect($data->get('missing'))->toBeNull(); + expect($data->get('missing', 'default'))->toBe('default'); + }); + + it('can check if key exists', function () { + $data = BlockData::fromArray(['title' => 'Test']); + + expect($data->has('title'))->toBeTrue(); + expect($data->has('missing'))->toBeFalse(); + }); + + it('can add new key-value pair', function () { + $data = BlockData::fromArray(['title' => 'Test']); + $newData = $data->with('content', 'Hello'); + + expect($newData->get('title'))->toBe('Test'); + expect($newData->get('content'))->toBe('Hello'); + expect($data->has('content'))->toBeFalse(); // Original unchanged + }); + + it('can remove key', function () { + $data = BlockData::fromArray(['title' => 'Test', 'content' => 'Hello']); + $newData = $data->without('title'); + + expect($newData->has('title'))->toBeFalse(); + expect($newData->has('content'))->toBeTrue(); + expect($data->has('title'))->toBeTrue(); // Original unchanged + }); + + it('can merge with another array', function () { + $data = BlockData::fromArray(['title' => 'Test']); + $merged = $data->merge(['content' => 'Hello', 'title' => 'Updated']); + + expect($merged->get('title'))->toBe('Updated'); + expect($merged->get('content'))->toBe('Hello'); + }); + + it('accepts scalar values', function () { + $data = BlockData::fromArray([ + 'string' => 'test', + 'int' => 123, + 'float' => 45.67, + 'bool' => true, + 'null' => null, + ]); + + expect($data->get('string'))->toBe('test'); + expect($data->get('int'))->toBe(123); + expect($data->get('float'))->toBe(45.67); + expect($data->get('bool'))->toBeTrue(); + expect($data->get('null'))->toBeNull(); + }); + + it('accepts arrays of scalar values', function () { + $data = BlockData::fromArray([ + 'tags' => ['php', 'testing', 'pest'], + 'numbers' => [1, 2, 3], + ]); + + expect($data->get('tags'))->toBe(['php', 'testing', 'pest']); + expect($data->get('numbers'))->toBe([1, 2, 3]); + }); + + it('accepts nested arrays', function () { + $data = BlockData::fromArray([ + 'config' => [ + 'width' => 100, + 'height' => 200, + ], + ]); + + expect($data->get('config'))->toBe(['width' => 100, 'height' => 200]); + }); + + it('throws exception for non-string keys', function () { + expect(fn () => BlockData::fromArray([0 => 'value'])) + ->toThrow(InvalidArgumentException::class, 'BlockData keys must be strings'); + }); + + it('throws exception for non-serializable values', function () { + expect(fn () => BlockData::fromArray(['key' => fopen('php://memory', 'r')])) + ->toThrow(InvalidArgumentException::class, 'BlockData value for key'); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/BlockIdTest.php b/tests/Unit/Domain/Cms/ValueObjects/BlockIdTest.php new file mode 100644 index 00000000..b8ea3239 --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/BlockIdTest.php @@ -0,0 +1,69 @@ +toString())->toBe('hero-1'); + expect((string) $blockId)->toBe('hero-1'); + }); + + it('accepts lowercase letters, numbers, hyphens, and underscores', function () { + $validIds = ['block-1', 'text_block', 'image123', 'my-block-id']; + + foreach ($validIds as $id) { + $blockId = BlockId::fromString($id); + expect($blockId->toString())->toBe($id); + } + }); + + it('can generate unique block IDs', function () { + $blockId1 = BlockId::generate('hero'); + $blockId2 = BlockId::generate('hero'); + + expect($blockId1->toString())->toStartWith('hero_'); + expect($blockId2->toString())->toStartWith('hero_'); + expect($blockId1->toString())->not->toBe($blockId2->toString()); + }); + + it('generates block IDs with custom prefix', function () { + $blockId = BlockId::generate('custom'); + + expect($blockId->toString())->toStartWith('custom_'); + }); + + it('throws exception for empty string', function () { + expect(fn () => BlockId::fromString('')) + ->toThrow(InvalidArgumentException::class, 'Block ID cannot be empty'); + }); + + it('throws exception for uppercase letters', function () { + expect(fn () => BlockId::fromString('Block-1')) + ->toThrow(InvalidArgumentException::class, 'Block ID must contain only lowercase letters, numbers, hyphens, and underscores'); + }); + + it('throws exception for special characters', function () { + expect(fn () => BlockId::fromString('block@1')) + ->toThrow(InvalidArgumentException::class); + }); + + it('throws exception for block ID exceeding 100 characters', function () { + $longId = str_repeat('a', 101); + expect(fn () => BlockId::fromString($longId)) + ->toThrow(InvalidArgumentException::class, 'Block ID cannot exceed 100 characters'); + }); + + it('can compare two BlockIds for equality', function () { + $id1 = BlockId::fromString('block-1'); + $id2 = BlockId::fromString('block-1'); + $id3 = BlockId::fromString('block-2'); + + expect($id1->equals($id2))->toBeTrue(); + expect($id1->equals($id3))->toBeFalse(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/BlockSettingsTest.php b/tests/Unit/Domain/Cms/ValueObjects/BlockSettingsTest.php new file mode 100644 index 00000000..e208e7d8 --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/BlockSettingsTest.php @@ -0,0 +1,102 @@ + true, 'padding' => 20]); + + expect($settings->toArray())->toBe(['fullWidth' => true, 'padding' => 20]); + }); + + it('can be created as empty', function () { + $settings = BlockSettings::empty(); + + expect($settings->toArray())->toBe([]); + expect($settings->has('key'))->toBeFalse(); + }); + + it('can get values by key', function () { + $settings = BlockSettings::fromArray(['fullWidth' => true, 'padding' => 20]); + + expect($settings->get('fullWidth'))->toBeTrue(); + expect($settings->get('padding'))->toBe(20); + expect($settings->get('missing'))->toBeNull(); + expect($settings->get('missing', 'default'))->toBe('default'); + }); + + it('can check if key exists', function () { + $settings = BlockSettings::fromArray(['fullWidth' => true]); + + expect($settings->has('fullWidth'))->toBeTrue(); + expect($settings->has('missing'))->toBeFalse(); + }); + + it('can add new key-value pair', function () { + $settings = BlockSettings::fromArray(['fullWidth' => true]); + $newSettings = $settings->with('padding', 20); + + expect($newSettings->get('fullWidth'))->toBeTrue(); + expect($newSettings->get('padding'))->toBe(20); + expect($settings->has('padding'))->toBeFalse(); // Original unchanged + }); + + it('can remove key', function () { + $settings = BlockSettings::fromArray(['fullWidth' => true, 'padding' => 20]); + $newSettings = $settings->without('fullWidth'); + + expect($newSettings->has('fullWidth'))->toBeFalse(); + expect($newSettings->has('padding'))->toBeTrue(); + expect($settings->has('fullWidth'))->toBeTrue(); // Original unchanged + }); + + it('accepts scalar values', function () { + $settings = BlockSettings::fromArray([ + 'string' => 'test', + 'int' => 123, + 'float' => 45.67, + 'bool' => true, + 'null' => null, + ]); + + expect($settings->get('string'))->toBe('test'); + expect($settings->get('int'))->toBe(123); + expect($settings->get('float'))->toBe(45.67); + expect($settings->get('bool'))->toBeTrue(); + expect($settings->get('null'))->toBeNull(); + }); + + it('accepts arrays of scalar values', function () { + $settings = BlockSettings::fromArray([ + 'classes' => ['container', 'full-width'], + 'numbers' => [1, 2, 3], + ]); + + expect($settings->get('classes'))->toBe(['container', 'full-width']); + expect($settings->get('numbers'))->toBe([1, 2, 3]); + }); + + it('accepts nested arrays', function () { + $settings = BlockSettings::fromArray([ + 'style' => [ + 'margin' => 10, + 'padding' => 20, + ], + ]); + + expect($settings->get('style'))->toBe(['margin' => 10, 'padding' => 20]); + }); + + it('throws exception for non-string keys', function () { + expect(fn () => BlockSettings::fromArray([0 => 'value'])) + ->toThrow(InvalidArgumentException::class, 'BlockSettings keys must be strings'); + }); + + it('throws exception for non-serializable values', function () { + expect(fn () => BlockSettings::fromArray(['key' => fopen('php://memory', 'r')])) + ->toThrow(InvalidArgumentException::class, 'BlockSettings value for key'); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/BlockTypeTest.php b/tests/Unit/Domain/Cms/ValueObjects/BlockTypeTest.php new file mode 100644 index 00000000..3466e0f1 --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/BlockTypeTest.php @@ -0,0 +1,81 @@ +toString())->toBe('hero'); + expect((string) $blockType)->toBe('hero'); + }); + + it('has factory methods for system block types', function () { + expect(BlockType::hero()->toString())->toBe('hero'); + expect(BlockType::text()->toString())->toBe('text'); + expect(BlockType::image()->toString())->toBe('image'); + expect(BlockType::gallery()->toString())->toBe('gallery'); + expect(BlockType::cta()->toString())->toBe('cta'); + expect(BlockType::video()->toString())->toBe('video'); + expect(BlockType::form()->toString())->toBe('form'); + expect(BlockType::columns()->toString())->toBe('columns'); + expect(BlockType::quote()->toString())->toBe('quote'); + expect(BlockType::separator()->toString())->toBe('separator'); + }); + + it('marks system block types correctly', function () { + expect(BlockType::hero()->isSystem())->toBeTrue(); + expect(BlockType::text()->isSystem())->toBeTrue(); + expect(BlockType::fromString('custom-block', false)->isSystem())->toBeFalse(); + }); + + it('throws exception for empty string', function () { + expect(fn () => BlockType::fromString('')) + ->toThrow(InvalidArgumentException::class, 'Block type cannot be empty'); + }); + + it('throws exception for uppercase letters', function () { + expect(fn () => BlockType::fromString('Hero')) + ->toThrow(InvalidArgumentException::class, 'Block type must contain only lowercase letters, numbers, hyphens, and underscores'); + }); + + it('throws exception for special characters', function () { + expect(fn () => BlockType::fromString('block@type')) + ->toThrow(InvalidArgumentException::class); + }); + + it('throws exception for block type exceeding 50 characters', function () { + $longType = str_repeat('a', 51); + expect(fn () => BlockType::fromString($longType)) + ->toThrow(InvalidArgumentException::class, 'Block type cannot exceed 50 characters'); + }); + + it('can identify block types that require media', function () { + expect(BlockType::hero()->requiresMedia())->toBeTrue(); + expect(BlockType::image()->requiresMedia())->toBeTrue(); + expect(BlockType::gallery()->requiresMedia())->toBeTrue(); + expect(BlockType::video()->requiresMedia())->toBeTrue(); + expect(BlockType::text()->requiresMedia())->toBeFalse(); + expect(BlockType::cta()->requiresMedia())->toBeFalse(); + }); + + it('provides labels for block types', function () { + expect(BlockType::hero()->getLabel())->toBe('Hero Section'); + expect(BlockType::text()->getLabel())->toBe('Text Block'); + expect(BlockType::image()->getLabel())->toBe('Image'); + expect(BlockType::gallery()->getLabel())->toBe('Gallery'); + expect(BlockType::cta()->getLabel())->toBe('Call to Action'); + }); + + it('can compare two BlockTypes for equality', function () { + $type1 = BlockType::fromString('hero'); + $type2 = BlockType::fromString('hero'); + $type3 = BlockType::fromString('text'); + + expect($type1->equals($type2))->toBeTrue(); + expect($type1->equals($type3))->toBeFalse(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/ContentBlockTest.php b/tests/Unit/Domain/Cms/ValueObjects/ContentBlockTest.php new file mode 100644 index 00000000..f1ec0c8c --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/ContentBlockTest.php @@ -0,0 +1,133 @@ + 'Hero Title']) + ); + + expect($block->type->toString())->toBe('hero'); + expect($block->blockId->toString())->toBe('hero-1'); + expect($block->data->get('title'))->toBe('Hero Title'); + expect($block->settings)->toBeNull(); + }); + + it('can be created with settings', function () { + $settings = BlockSettings::fromArray(['fullWidth' => true]); + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Hero Title']), + settings: $settings + ); + + expect($block->settings)->not->toBeNull(); + expect($block->settings->get('fullWidth'))->toBeTrue(); + }); + + it('can be created from array', function () { + $block = ContentBlock::fromArray([ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero Title'], + 'settings' => ['fullWidth' => true], + ]); + + expect($block->blockId->toString())->toBe('hero-1'); + expect($block->type->toString())->toBe('hero'); + expect($block->data->get('title'))->toBe('Hero Title'); + expect($block->settings->get('fullWidth'))->toBeTrue(); + }); + + it('can be created from array without settings', function () { + $block = ContentBlock::fromArray([ + 'id' => 'text-1', + 'type' => 'text', + 'data' => ['content' => 'Hello World'], + ]); + + expect($block->settings)->toBeNull(); + }); + + it('throws exception when creating from array with missing required fields', function () { + expect(fn () => ContentBlock::fromArray(['id' => 'hero-1'])) + ->toThrow(InvalidArgumentException::class, 'Invalid block data: missing required fields'); + + expect(fn () => ContentBlock::fromArray(['type' => 'hero'])) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => ContentBlock::fromArray(['id' => 'hero-1', 'type' => 'hero'])) + ->toThrow(InvalidArgumentException::class); + }); + + it('can convert to array', function () { + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Hero Title']), + settings: BlockSettings::fromArray(['fullWidth' => true]) + ); + + $array = $block->toArray(); + + expect($array)->toHaveKeys(['id', 'type', 'data', 'settings']); + expect($array['id'])->toBe('hero-1'); + expect($array['type'])->toBe('hero'); + expect($array['data'])->toBe(['title' => 'Hero Title']); + expect($array['settings'])->toBe(['fullWidth' => true]); + }); + + it('can update data immutably', function () { + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Old Title']) + ); + + $newData = BlockData::fromArray(['title' => 'New Title']); + $updatedBlock = $block->withData($newData); + + expect($updatedBlock->data->get('title'))->toBe('New Title'); + expect($block->data->get('title'))->toBe('Old Title'); // Original unchanged + }); + + it('can update settings immutably', function () { + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Hero Title']) + ); + + $newSettings = BlockSettings::fromArray(['fullWidth' => true]); + $updatedBlock = $block->withSettings($newSettings); + + expect($updatedBlock->settings)->not->toBeNull(); + expect($updatedBlock->settings->get('fullWidth'))->toBeTrue(); + expect($block->settings)->toBeNull(); // Original unchanged + }); + + it('can remove settings', function () { + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Hero Title']), + settings: BlockSettings::fromArray(['fullWidth' => true]) + ); + + $updatedBlock = $block->withSettings(null); + + expect($updatedBlock->settings)->toBeNull(); + expect($block->settings)->not->toBeNull(); // Original unchanged + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/ContentBlocksTest.php b/tests/Unit/Domain/Cms/ValueObjects/ContentBlocksTest.php new file mode 100644 index 00000000..51e02c83 --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/ContentBlocksTest.php @@ -0,0 +1,177 @@ +isEmpty())->toBeTrue(); + expect($blocks->count())->toBe(0); + }); + + it('can be created from array', function () { + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero'], + ], + [ + 'id' => 'text-1', + 'type' => 'text', + 'data' => ['content' => 'Text'], + ], + ]); + + expect($blocks->count())->toBe(2); + expect($blocks->isEmpty())->toBeFalse(); + }); + + it('can add blocks', function () { + $blocks = ContentBlocks::empty(); + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Hero']) + ); + + $newBlocks = $blocks->add($block); + + expect($newBlocks->count())->toBe(1); + expect($blocks->count())->toBe(0); // Original unchanged + }); + + it('throws exception when adding duplicate block ID', function () { + $block = ContentBlock::create( + type: BlockType::hero(), + blockId: BlockId::fromString('hero-1'), + data: BlockData::fromArray(['title' => 'Hero']) + ); + + $blocks = ContentBlocks::fromArray([$block->toArray()]); + + expect(fn () => $blocks->add($block)) + ->toThrow(\InvalidArgumentException::class); + }); + + it('can remove blocks', function () { + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero'], + ], + [ + 'id' => 'text-1', + 'type' => 'text', + 'data' => ['content' => 'Text'], + ], + ]); + + $newBlocks = $blocks->remove(BlockId::fromString('hero-1')); + + expect($newBlocks->count())->toBe(1); + expect($blocks->count())->toBe(2); // Original unchanged + expect($newBlocks->findById(BlockId::fromString('text-1')))->not->toBeNull(); + }); + + it('can find block by ID', function () { + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero'], + ], + [ + 'id' => 'text-1', + 'type' => 'text', + 'data' => ['content' => 'Text'], + ], + ]); + + $found = $blocks->findById(BlockId::fromString('hero-1')); + expect($found)->not->toBeNull(); + expect($found->blockId->toString())->toBe('hero-1'); + + $notFound = $blocks->findById(BlockId::fromString('missing')); + expect($notFound)->toBeNull(); + }); + + it('can be iterated', function () { + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero'], + ], + [ + 'id' => 'text-1', + 'type' => 'text', + 'data' => ['content' => 'Text'], + ], + ]); + + $count = 0; + foreach ($blocks as $block) { + expect($block)->toBeInstanceOf(ContentBlock::class); + $count++; + } + + expect($count)->toBe(2); + }); + + it('can convert to array', function () { + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero'], + ], + ]); + + $array = $blocks->toArray(); + + expect($array)->toBeArray(); + expect($array)->toHaveCount(1); + expect($array[0]['id'])->toBe('hero-1'); + }); + + it('throws exception when creating with duplicate block IDs', function () { + expect(fn () => ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero'], + ], + [ + 'id' => 'hero-1', + 'type' => 'text', + 'data' => ['content' => 'Text'], + ], + ]))->toThrow(InvalidArgumentException::class, 'Duplicate block ID'); + }); + + it('can get all blocks', function () { + $blocks = ContentBlocks::fromArray([ + [ + 'id' => 'hero-1', + 'type' => 'hero', + 'data' => ['title' => 'Hero'], + ], + ]); + + $allBlocks = $blocks->getBlocks(); + + expect($allBlocks)->toBeArray(); + expect($allBlocks)->toHaveCount(1); + expect($allBlocks[0])->toBeInstanceOf(ContentBlock::class); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/ContentIdTest.php b/tests/Unit/Domain/Cms/ValueObjects/ContentIdTest.php new file mode 100644 index 00000000..dfbc1cdc --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/ContentIdTest.php @@ -0,0 +1,53 @@ +toString())->toBe($validUlid); + expect((string) $contentId)->toBe($validUlid); + }); + + it('throws exception for invalid ULID format', function () { + expect(fn () => ContentId::fromString('invalid-id')) + ->toThrow(InvalidArgumentException::class, 'Invalid Content ID format'); + }); + + it('throws exception for empty string', function () { + expect(fn () => ContentId::fromString('')) + ->toThrow(InvalidArgumentException::class); + }); + + it('can generate new ContentId with Clock', function () { + $clock = new SystemClock(); + $contentId = ContentId::generate($clock); + + expect($contentId)->toBeInstanceOf(ContentId::class); + expect($contentId->toString())->toMatch('/^[0-9A-Z]{26}$/'); + }); + + it('generates unique IDs', function () { + $clock = new SystemClock(); + $id1 = ContentId::generate($clock); + $id2 = ContentId::generate($clock); + + expect($id1->toString())->not->toBe($id2->toString()); + }); + + it('can compare two ContentIds for equality', function () { + $id1 = ContentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $id2 = ContentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + $id3 = ContentId::fromString('01ARZ3NDEKTSV4RRFFQ69G5FAW'); + + expect($id1->equals($id2))->toBeTrue(); + expect($id1->equals($id3))->toBeFalse(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/ContentSlugTest.php b/tests/Unit/Domain/Cms/ValueObjects/ContentSlugTest.php new file mode 100644 index 00000000..361825a3 --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/ContentSlugTest.php @@ -0,0 +1,53 @@ +toString())->toBe('my-awesome-page'); + expect((string) $slug)->toBe('my-awesome-page'); + }); + + it('accepts lowercase letters, numbers, hyphens, and underscores', function () { + $validSlugs = ['page', 'blog-post', 'product_123', 'my-content-slug']; + + foreach ($validSlugs as $slugStr) { + $slug = ContentSlug::fromString($slugStr); + expect($slug->toString())->toBe($slugStr); + } + }); + + it('throws exception for empty string', function () { + expect(fn () => ContentSlug::fromString('')) + ->toThrow(InvalidArgumentException::class, 'Content slug cannot be empty'); + }); + + it('throws exception for uppercase letters', function () { + expect(fn () => ContentSlug::fromString('My-Page')) + ->toThrow(InvalidArgumentException::class, 'Content slug must contain only lowercase letters, numbers, hyphens, and underscores'); + }); + + it('throws exception for spaces', function () { + expect(fn () => ContentSlug::fromString('my page')) + ->toThrow(InvalidArgumentException::class); + }); + + it('throws exception for special characters', function () { + expect(fn () => ContentSlug::fromString('my@page')) + ->toThrow(InvalidArgumentException::class); + }); + + it('can compare two ContentSlugs for equality', function () { + $slug1 = ContentSlug::fromString('my-page'); + $slug2 = ContentSlug::fromString('my-page'); + $slug3 = ContentSlug::fromString('other-page'); + + expect($slug1->equals($slug2))->toBeTrue(); + expect($slug1->equals($slug3))->toBeFalse(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/ContentTypeIdTest.php b/tests/Unit/Domain/Cms/ValueObjects/ContentTypeIdTest.php new file mode 100644 index 00000000..81617ad5 --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/ContentTypeIdTest.php @@ -0,0 +1,53 @@ +toString())->toBe('landing_page'); + expect((string) $contentTypeId)->toBe('landing_page'); + }); + + it('accepts lowercase letters, numbers, and underscores', function () { + $validIds = ['page', 'blog_post', 'product123', 'my_content_type']; + + foreach ($validIds as $id) { + $contentTypeId = ContentTypeId::fromString($id); + expect($contentTypeId->toString())->toBe($id); + } + }); + + it('throws exception for empty string', function () { + expect(fn () => ContentTypeId::fromString('')) + ->toThrow(InvalidArgumentException::class, 'ContentType ID cannot be empty'); + }); + + it('throws exception for uppercase letters', function () { + expect(fn () => ContentTypeId::fromString('LandingPage')) + ->toThrow(InvalidArgumentException::class, 'ContentType ID must contain only lowercase letters, numbers, and underscores'); + }); + + it('throws exception for special characters', function () { + expect(fn () => ContentTypeId::fromString('landing-page')) + ->toThrow(InvalidArgumentException::class); + }); + + it('throws exception for spaces', function () { + expect(fn () => ContentTypeId::fromString('landing page')) + ->toThrow(InvalidArgumentException::class); + }); + + it('can compare two ContentTypeIds for equality', function () { + $id1 = ContentTypeId::fromString('page'); + $id2 = ContentTypeId::fromString('page'); + $id3 = ContentTypeId::fromString('blog'); + + expect($id1->equals($id2))->toBeTrue(); + expect($id1->equals($id3))->toBeFalse(); + }); +}); + diff --git a/tests/Unit/Domain/Cms/ValueObjects/LocaleTest.php b/tests/Unit/Domain/Cms/ValueObjects/LocaleTest.php new file mode 100644 index 00000000..6829867a --- /dev/null +++ b/tests/Unit/Domain/Cms/ValueObjects/LocaleTest.php @@ -0,0 +1,69 @@ +toString())->toBe('en'); + expect((string) $locale)->toBe('en'); + }); + + it('accepts locale with region', function () { + $locale = Locale::fromString('en-US'); + + expect($locale->toString())->toBe('en-US'); + expect($locale->getLanguage())->toBe('en'); + expect($locale->getRegion())->toBe('US'); + }); + + it('has factory methods for common locales', function () { + expect(Locale::english()->toString())->toBe('en'); + expect(Locale::german()->toString())->toBe('de'); + expect(Locale::french()->toString())->toBe('fr'); + expect(Locale::spanish()->toString())->toBe('es'); + }); + + it('extracts language from locale', function () { + expect(Locale::fromString('en')->getLanguage())->toBe('en'); + expect(Locale::fromString('en-US')->getLanguage())->toBe('en'); + expect(Locale::fromString('de-CH')->getLanguage())->toBe('de'); + }); + + it('extracts region from locale with region', function () { + expect(Locale::fromString('en-US')->getRegion())->toBe('US'); + expect(Locale::fromString('de-CH')->getRegion())->toBe('CH'); + }); + + it('returns null for region when locale has no region', function () { + expect(Locale::fromString('en')->getRegion())->toBeNull(); + expect(Locale::fromString('de')->getRegion())->toBeNull(); + }); + + it('throws exception for invalid locale format', function () { + expect(fn () => Locale::fromString('invalid')) + ->toThrow(InvalidArgumentException::class, 'Invalid locale format'); + + expect(fn () => Locale::fromString('EN')) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => Locale::fromString('en-us')) + ->toThrow(InvalidArgumentException::class); + }); + + it('can compare two Locales for equality', function () { + $locale1 = Locale::fromString('en'); + $locale2 = Locale::fromString('en'); + $locale3 = Locale::fromString('de'); + + expect($locale1->equals($locale2))->toBeTrue(); + expect($locale1->equals($locale3))->toBeFalse(); + + $locale4 = Locale::fromString('en-US'); + expect($locale1->equals($locale4))->toBeFalse(); + }); +}); +