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

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

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

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

View File

@@ -0,0 +1,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();
});
});