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

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

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

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

View File

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

View File

@@ -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];
}

View File

@@ -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)
)
);

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Domain\Cms\Exceptions;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Framework\Exception\Core\ValidationErrorCode;
use App\Framework\Exception\FrameworkException;
final class CannotDeleteContentTypeInUseException extends FrameworkException
{
public static function forId(ContentTypeId $id, int $contentCount): self
{
return new self(
sprintf(
'Cannot delete content type with ID "%s" because it is still used by %d content item(s)',
$id->toString(),
$contentCount
),
(int) ValidationErrorCode::BUSINESS_RULE_VIOLATION->getNumericCode()
);
}
}

View File

@@ -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'
]);
```

View File

@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace App\Domain\Cms\Rendering;
final readonly class BlockRendererRegistry
final class BlockRendererRegistry
{
/**
* @var array<string, BlockRendererInterface>
*/
private array $renderers;
public function __construct()
{
$this->renderers = [];
}
public function register(BlockRendererInterface $renderer): void
{
// Register renderer for all supported block types

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

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

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
use App\Application\Asset\Api\V1\AssetsController;
use App\Domain\Asset\Entities\Asset;
use App\Domain\Asset\Entities\AssetTag;
use App\Domain\Asset\Entities\AssetVariant;
use App\Domain\Asset\Services\AssetService;
use App\Domain\Asset\Storage\AssetStorageInterface;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\VariantName;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\RequestId;
use App\Framework\Http\Status;
use App\Framework\Serializer\Json\JsonSerializer;
describe('AssetsController', function () {
beforeEach(function () {
$this->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);
});
});

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
use App\Application\Cms\Api\V1\ContentTypesController;
use App\Domain\Cms\Entities\ContentType;
use App\Domain\Cms\Services\ContentTypeService;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\RequestId;
use App\Framework\Http\Status;
use App\Framework\Serializer\Json\JsonSerializer;
describe('ContentTypesController', function () {
beforeEach(function () {
$this->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);
});
});

View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
use App\Application\Cms\Api\V1\ContentsController;
use App\Domain\Cms\Entities\Content;
use App\Domain\Cms\Enums\ContentStatus;
use App\Domain\Cms\Services\ContentService;
use App\Domain\Cms\ValueObjects\ContentBlocks;
use App\Domain\Cms\ValueObjects\ContentId;
use App\Domain\Cms\ValueObjects\ContentSlug;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Domain\Cms\ValueObjects\Locale;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\RequestId;
use App\Framework\Http\Status;
use App\Framework\Serializer\Json\JsonSerializer;
describe('ContentsController', function () {
beforeEach(function () {
$this->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);
});
});

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Domain\Asset\Entities\Asset;
use App\Domain\Asset\Entities\AssetTag;
use App\Domain\Asset\Entities\AssetVariant;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\AssetMetadata;
use App\Domain\Asset\ValueObjects\VariantName;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Clock;
use App\Framework\Http\MimeType;
use App\Framework\Storage\ValueObjects\BucketName;
use App\Framework\Storage\ValueObjects\ObjectKey;
final class AssetTestHelpers
{
public static function createAsset(
Clock $clock,
?AssetId $id = null,
?BucketName $bucket = null,
?ObjectKey $key = null,
?MimeType $mime = null,
?FileSize $bytes = null,
?Hash $sha256 = null,
?AssetMetadata $meta = null
): Asset {
return new Asset(
id: $id ?? AssetId::generate($clock),
bucket: $bucket ?? BucketName::fromString('media'),
key: $key ?? ObjectKey::fromString('orig/2025/01/15/test.jpg'),
mime: $mime ?? MimeType::IMAGE_JPEG,
bytes: $bytes ?? FileSize::fromBytes(1024),
sha256: $sha256 ?? Hash::create('test-content', HashAlgorithm::SHA256),
meta: $meta ?? AssetMetadata::empty(),
createdAt: Timestamp::now()
);
}
public static function createAssetVariant(
AssetId $assetId,
?VariantName $variant = null,
?BucketName $bucket = null,
?ObjectKey $key = null,
?MimeType $mime = null,
?FileSize $bytes = null,
?AssetMetadata $meta = null
): AssetVariant {
return new AssetVariant(
assetId: $assetId,
variant: $variant ?? VariantName::fromString('1200w.webp'),
bucket: $bucket ?? BucketName::fromString('variants'),
key: $key ?? ObjectKey::fromString('variants/2025/01/15/test/1200w.webp'),
mime: $mime ?? MimeType::IMAGE_WEBP,
bytes: $bytes ?? FileSize::fromBytes(512),
meta: $meta ?? AssetMetadata::fromArray(['width' => 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);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Domain\Cms\Entities\Content;
use App\Domain\Cms\Entities\ContentType;
use App\Domain\Cms\Enums\ContentStatus;
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
use App\Domain\Cms\ValueObjects\ContentBlocks;
use App\Domain\Cms\ValueObjects\ContentId;
use App\Domain\Cms\ValueObjects\ContentSlug;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Domain\Cms\ValueObjects\Locale;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Clock;
final class CmsTestHelpers
{
public static function createContent(
Clock $clock,
?ContentId $id = null,
?ContentTypeId $contentTypeId = null,
?ContentSlug $slug = null,
?string $title = null,
?ContentBlocks $blocks = null,
?ContentStatus $status = null,
?Locale $defaultLocale = null
): Content {
return new Content(
id: $id ?? ContentId::generate($clock),
contentTypeId: $contentTypeId ?? ContentTypeId::fromString('page'),
slug: $slug ?? ContentSlug::fromString('test-page'),
title: $title ?? 'Test Page',
blocks: $blocks ?? self::createSimpleBlocks(),
status: $status ?? ContentStatus::DRAFT,
authorId: null,
publishedAt: null,
metaData: null,
defaultLocale: $defaultLocale ?? Locale::english(),
createdAt: Timestamp::now(),
updatedAt: Timestamp::now()
);
}
public static function createContentType(
?ContentTypeId $id = null,
?string $name = null,
?string $slug = null,
bool $isSystem = false
): ContentType {
return new ContentType(
id: $id ?? ContentTypeId::fromString('page'),
name: $name ?? 'Page',
slug: $slug ?? 'page',
description: 'A test page content type',
isSystem: $isSystem,
createdAt: Timestamp::now(),
updatedAt: Timestamp::now()
);
}
public static function createSimpleBlocks(): ContentBlocks
{
return ContentBlocks::fromArray([
[
'id' => '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()
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Rendering\BlockRendererInterface;
use App\Domain\Cms\Rendering\BlockRendererRegistry;
use App\Domain\Cms\Rendering\HeroBlockRenderer;
use App\Domain\Cms\Rendering\TextBlockRenderer;
describe('BlockRendererRegistry', function () {
it('can register renderer for specific type', function () {
$registry = new BlockRendererRegistry();
$renderer = new HeroBlockRenderer();
$registry->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);
});
});

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Entities\Content;
use App\Domain\Cms\Enums\ContentStatus;
use App\Domain\Cms\Rendering\BlockRendererRegistry;
use App\Domain\Cms\Rendering\DefaultBlockRenderer;
use App\Domain\Cms\Rendering\HeroBlockRenderer;
use App\Domain\Cms\Rendering\TextBlockRenderer;
use App\Domain\Cms\Services\ContentLocalizationService;
use App\Domain\Cms\Services\ContentRenderer;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlocks;
use App\Domain\Cms\ValueObjects\ContentId;
use App\Domain\Cms\ValueObjects\ContentSlug;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Domain\Cms\ValueObjects\Locale;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use App\Framework\DI\DefaultContainer;
use App\Framework\Filesystem\FileStorage;
use App\Framework\Cache\Cache;
use App\Framework\Cache\Driver\NullCache;
use App\Framework\Cache\GeneralCache;
use App\Framework\Core\PathProvider;
use App\Framework\Serializer\Serializer;
use App\Framework\View\ComponentCache;
use App\Framework\View\ComponentRenderer;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\TemplateProcessor;
use Tests\Support\CmsTestHelpers;
describe('ContentRenderer', function () {
beforeEach(function () {
$this->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();
});
});

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Rendering\DefaultBlockRenderer;
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockSettings;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
describe('DefaultBlockRenderer', function () {
it('renders any block type as generic block', 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)->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();
});
});

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Rendering\HeroBlockRenderer;
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockSettings;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
describe('HeroBlockRenderer', function () {
it('renders hero block with title', 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)->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();
});
});

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Rendering\ImageBlockRenderer;
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockSettings;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
describe('ImageBlockRenderer', function () {
it('renders image block with imageId', 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)->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();
});
});

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Rendering\TextBlockRenderer;
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockSettings;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
describe('TextBlockRenderer', function () {
it('renders text block with content', 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)->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();
});
});

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Entities\Content;
use App\Domain\Cms\Enums\ContentStatus;
use App\Domain\Cms\Repositories\DatabaseContentRepository;
use App\Domain\Cms\ValueObjects\ContentId;
use App\Domain\Cms\ValueObjects\ContentSlug;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ResultInterface;
use Tests\Support\CmsTestHelpers;
describe('DatabaseContentRepository', function () {
beforeEach(function () {
$this->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);
});
});

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Entities\ContentTranslation;
use App\Domain\Cms\Repositories\DatabaseContentTranslationRepository;
use App\Domain\Cms\ValueObjects\ContentId;
use App\Domain\Cms\ValueObjects\Locale;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ResultInterface;
use Tests\Support\CmsTestHelpers;
describe('DatabaseContentTranslationRepository', function () {
beforeEach(function () {
$this->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);
});
});

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Entities\ContentType;
use App\Domain\Cms\Repositories\DatabaseContentTypeRepository;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ResultInterface;
use Tests\Support\CmsTestHelpers;
describe('DatabaseContentTypeRepository', function () {
beforeEach(function () {
$this->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);
});
});

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Exceptions\InvalidBlockException;
use App\Domain\Cms\Services\BlockTypeRegistry;
use App\Domain\Cms\Services\BlockValidator;
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
describe('BlockValidator', function () {
beforeEach(function () {
$this->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');
});
});

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Entities\Content;
use App\Domain\Cms\Entities\ContentTranslation;
use App\Domain\Cms\Enums\ContentStatus;
use App\Domain\Cms\Repositories\ContentTranslationRepository;
use App\Domain\Cms\Services\ContentLocalizationService;
use App\Domain\Cms\ValueObjects\ContentBlocks;
use App\Domain\Cms\ValueObjects\ContentId;
use App\Domain\Cms\ValueObjects\ContentSlug;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Domain\Cms\ValueObjects\Locale;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use Tests\Support\CmsTestHelpers;
describe('ContentLocalizationService', function () {
beforeEach(function () {
$this->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);
});
});

View File

@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Entities\Content;
use App\Domain\Cms\Enums\ContentStatus;
use App\Domain\Cms\Exceptions\ContentNotFoundException;
use App\Domain\Cms\Exceptions\DuplicateSlugException;
use App\Domain\Cms\Exceptions\InvalidContentStatusException;
use App\Domain\Cms\Repositories\ContentRepository;
use App\Domain\Cms\Services\BlockTypeRegistry;
use App\Domain\Cms\Services\BlockValidator;
use App\Domain\Cms\Services\ContentService;
use App\Domain\Cms\Services\SlugGenerator;
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
use App\Domain\Cms\ValueObjects\ContentBlocks;
use App\Domain\Cms\ValueObjects\ContentId;
use App\Domain\Cms\ValueObjects\ContentSlug;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Domain\Cms\ValueObjects\Locale;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use Tests\Support\CmsTestHelpers;
describe('ContentService', function () {
beforeEach(function () {
$this->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');
});
});

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Entities\ContentType;
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\Services\ContentTypeService;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\SystemClock;
use Tests\Support\CmsTestHelpers;
describe('ContentTypeService', function () {
beforeEach(function () {
$this->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);
});
});

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\Repositories\ContentRepository;
use App\Domain\Cms\Services\SlugGenerator;
use App\Domain\Cms\ValueObjects\ContentSlug;
describe('SlugGenerator', function () {
beforeEach(function () {
$this->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');
});
});

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\BlockData;
describe('BlockData', function () {
it('can be created from array', function () {
$data = BlockData::fromArray(['title' => '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');
});
});

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\BlockId;
describe('BlockId', function () {
it('can be created from valid string', function () {
$blockId = BlockId::fromString('hero-1');
expect($blockId->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();
});
});

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\BlockSettings;
describe('BlockSettings', function () {
it('can be created from array', function () {
$settings = BlockSettings::fromArray(['fullWidth' => 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');
});
});

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\BlockType;
describe('BlockType', function () {
it('can be created from string', function () {
$blockType = BlockType::fromString('hero');
expect($blockType->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();
});
});

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockSettings;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
describe('ContentBlock', function () {
it('can be created with required fields', function () {
$block = ContentBlock::create(
type: BlockType::hero(),
blockId: BlockId::fromString('hero-1'),
data: BlockData::fromArray(['title' => '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
});
});

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\BlockId;
use App\Domain\Cms\ValueObjects\BlockType;
use App\Domain\Cms\ValueObjects\ContentBlock;
use App\Domain\Cms\ValueObjects\ContentBlocks;
describe('ContentBlocks', function () {
it('can be created as empty', function () {
$blocks = ContentBlocks::empty();
expect($blocks->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);
});
});

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\ContentId;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
describe('ContentId', function () {
it('can be created from valid ULID string', function () {
$validUlid = '01ARZ3NDEKTSV4RRFFQ69G5FAV';
$contentId = ContentId::fromString($validUlid);
expect($contentId->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();
});
});

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\ContentSlug;
describe('ContentSlug', function () {
it('can be created from valid string', function () {
$slug = ContentSlug::fromString('my-awesome-page');
expect($slug->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();
});
});

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\ContentTypeId;
describe('ContentTypeId', function () {
it('can be created from valid string', function () {
$contentTypeId = ContentTypeId::fromString('landing_page');
expect($contentTypeId->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();
});
});

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Domain\Cms\ValueObjects\Locale;
describe('Locale', function () {
it('can be created from valid locale string', function () {
$locale = Locale::fromString('en');
expect($locale->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();
});
});