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