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