diff --git a/ai/backend-context.md b/ai/backend-context.md index dd69678..34729b2 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -20,6 +20,9 @@ intentionally omitted here - update this section as entities land. - Do not write unit tests for concrete repository implementations (e.g. `Postgres*Repository`). They are exercised by e2e tests. Use cases are tested with fake repositories. + - Do not write reflection-only contract tests for repository interface + method names, parameter counts, parameter names, or type hints. PHP + interfaces already enforce those signatures; test behavior instead. - Repository methods that find records by a foreign key should accept the related entity, not a raw id (e.g. `findBySet(Set $set)`, not `findBySetId(int $setId)`). diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 1c346c6..6a63a85 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -2,16 +2,22 @@ namespace App\Controllers; +use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\GetElement\GetElementRequest; +use App\Element\UseCases\UpdateElement\UpdateElement; +use App\Element\UseCases\UpdateElement\UpdateElementRequest; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class ElementController { - public function __construct(private GetElement $getElement) - { + public function __construct( + private GetElement $getElement, + private UpdateElement $updateElement, + ) { } public function show(?int $id): JsonResponse @@ -42,15 +48,73 @@ class ElementController return new JsonResponse([ 'childElements' => $childElements, - 'element' => [ - 'id' => $element->getId(), - 'title' => $element->getTitle(), - 'description' => $element->getDescription(), - 'iconImageUrl' => $element->getIconImageUrl(), - 'richText' => $element->getRichText(), - 'pdfPath' => $element->getPdfPath(), - 'youtubeUrl' => $element->getYoutubeUrl(), - ], + 'element' => $this->buildElementPayload($element), ], 200); } + + public function update(?int $id, Request $request): JsonResponse + { + try { + $element = $this->updateElement->execute( + new UpdateElementRequest( + id: $id, + title: $this->stringInput($request, 'title'), + description: $this->stringInput($request, 'description'), + iconImageUrl: $this->stringInput( + $request, + 'iconImageUrl', + ), + richText: $this->stringInput($request, 'richText'), + pdfPath: $this->stringInput($request, 'pdfPath'), + youtubeUrl: $this->stringInput($request, 'youtubeUrl'), + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'element' => $this->buildElementPayload($element), + ], 200); + } + + private function stringInput(Request $request, string $key): ?string + { + $value = $request->input($key); + if (! is_string($value)) { + return null; + } + + return $value; + } + + /** + * @return array{ + * id: int, + * title: string, + * description: string, + * iconImageUrl: string|null, + * richText: string, + * pdfPath: string|null, + * youtubeUrl: string|null, + * } + */ + private function buildElementPayload(Element $element): array + { + return [ + 'id' => $element->getId(), + 'title' => $element->getTitle(), + 'description' => $element->getDescription(), + 'iconImageUrl' => $element->getIconImageUrl(), + 'richText' => $element->getRichText(), + 'pdfPath' => $element->getPdfPath(), + 'youtubeUrl' => $element->getYoutubeUrl(), + ]; + } } diff --git a/backend/app/Element/Element.php b/backend/app/Element/Element.php index 41fec0d..37be364 100644 --- a/backend/app/Element/Element.php +++ b/backend/app/Element/Element.php @@ -29,31 +29,61 @@ class Element return $this->title; } + public function setTitle(string $title): void + { + $this->title = $title; + } + public function getDescription(): string { return $this->description; } + public function setDescription(string $description): void + { + $this->description = $description; + } + public function getIconImageUrl(): ?string { return $this->iconImageUrl; } + public function setIconImageUrl(?string $iconImageUrl): void + { + $this->iconImageUrl = $iconImageUrl; + } + public function getRichText(): string { return $this->richText; } + public function setRichText(string $richText): void + { + $this->richText = $richText; + } + public function getPdfPath(): ?string { return $this->pdfPath; } + public function setPdfPath(?string $pdfPath): void + { + $this->pdfPath = $pdfPath; + } + public function getYoutubeUrl(): ?string { return $this->youtubeUrl; } + public function setYoutubeUrl(?string $youtubeUrl): void + { + $this->youtubeUrl = $youtubeUrl; + } + public function getSet(): Set { return $this->set; diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index 3e89628..e4fa74e 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -8,6 +8,8 @@ interface ElementRepository { public function create(CreateElementDto $dto): Element; + public function update(Element $element): Element; + public function find(int $id): ?Element; public function findRootBySet(DomainSet $set): ?Element; diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index b8c06d7..379abe0 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -38,6 +38,28 @@ class EloquentElementRepository implements ElementRepository ); } + public function update(Element $element): Element + { + $model = ElementModel::find($element->getId()); + if ($model === null) { + throw new DomainException( + "Element with id: {$element->getId()} doesnt exist" + ); + } + + $model->set_id = $element->getSet()->getId(); + $model->title = $element->getTitle(); + $model->description = $element->getDescription(); + $model->icon_image_url = $element->getIconImageUrl(); + $model->rich_text = $element->getRichText(); + $model->pdf_path = $element->getPdfPath(); + $model->youtube_url = $element->getYoutubeUrl(); + $model->parent_element_id = $element->getParentElement()?->getId(); + $model->save(); + + return $this->toDomain($model); + } + public function find(int $id): ?Element { $model = ElementModel::find($id); diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateDescription.php b/backend/app/Element/UseCases/UpdateElement/UpdateDescription.php new file mode 100644 index 0000000..6e277b9 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateDescription.php @@ -0,0 +1,39 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->description === null) { + throw new BadRequestException('description is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setDescription($request->description); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateDescriptionRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateDescriptionRequest.php new file mode 100644 index 0000000..882d4ff --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateDescriptionRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + $hasNoFields = $request->title === null + && $request->description === null + && $request->iconImageUrl === null + && $request->richText === null + && $request->pdfPath === null + && $request->youtubeUrl === null; + if ($hasNoFields) { + throw new BadRequestException('nothing to update'); + } + + if ($request->title !== null) { + $this->updateTitle->execute(new UpdateTitleRequest( + id: $request->id, + title: $request->title, + )); + } + if ($request->description !== null) { + $this->updateDescription->execute(new UpdateDescriptionRequest( + id: $request->id, + description: $request->description, + )); + } + if ($request->iconImageUrl !== null) { + $this->updateIconImageUrl->execute( + new UpdateIconImageUrlRequest( + id: $request->id, + iconImageUrl: $request->iconImageUrl, + ) + ); + } + if ($request->richText !== null) { + $this->updateRichText->execute(new UpdateRichTextRequest( + id: $request->id, + richText: $request->richText, + )); + } + if ($request->pdfPath !== null) { + $this->updatePdfPath->execute(new UpdatePdfPathRequest( + id: $request->id, + pdfPath: $request->pdfPath, + )); + } + if ($request->youtubeUrl !== null) { + $this->updateYoutubeUrl->execute(new UpdateYoutubeUrlRequest( + id: $request->id, + youtubeUrl: $request->youtubeUrl, + )); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + return $element; + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php new file mode 100644 index 0000000..0b5e766 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php @@ -0,0 +1,17 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->iconImageUrl === null) { + throw new BadRequestException('iconImageUrl is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setIconImageUrl($this->nullableString( + $request->iconImageUrl, + )); + + return $this->elementRepository->update($element); + } + + private function nullableString(string $value): ?string + { + if ($value === '') { + return null; + } + + return $value; + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateIconImageUrlRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateIconImageUrlRequest.php new file mode 100644 index 0000000..d328af3 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateIconImageUrlRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->pdfPath === null) { + throw new BadRequestException('pdfPath is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setPdfPath($this->nullableString($request->pdfPath)); + + return $this->elementRepository->update($element); + } + + private function nullableString(string $value): ?string + { + if ($value === '') { + return null; + } + + return $value; + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdatePdfPathRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdatePdfPathRequest.php new file mode 100644 index 0000000..c48c47d --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdatePdfPathRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->richText === null) { + throw new BadRequestException('richText is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setRichText($request->richText); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateRichTextRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateRichTextRequest.php new file mode 100644 index 0000000..fe29972 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateRichTextRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->title === null || trim($request->title) === '') { + throw new BadRequestException('title is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setTitle(trim($request->title)); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateTitleRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateTitleRequest.php new file mode 100644 index 0000000..5a13ea9 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateTitleRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->youtubeUrl === null) { + throw new BadRequestException('youtubeUrl is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setYoutubeUrl($this->nullableString( + $request->youtubeUrl, + )); + + return $this->elementRepository->update($element); + } + + private function nullableString(string $value): ?string + { + if ($value === '') { + return null; + } + + return $value; + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateYoutubeUrlRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateYoutubeUrlRequest.php new file mode 100644 index 0000000..3a7e06a --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateYoutubeUrlRequest.php @@ -0,0 +1,12 @@ + ['api/*'], - 'allowed_methods' => ['GET', 'POST', 'OPTIONS'], + 'allowed_methods' => ['GET', 'POST', 'PATCH', 'OPTIONS'], 'allowed_origins' => $allowedOrigins, 'allowed_origins_patterns' => [], 'allowed_headers' => [ diff --git a/backend/routes/api.php b/backend/routes/api.php index f32b2ca..9d253d0 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -12,3 +12,5 @@ Route::get('/me', [AuthController::class, 'me']) ->middleware(AuthMiddleware::class); Route::get('/sets', [SetController::class, 'index']); Route::get('/elements/{id}', [ElementController::class, 'show']); +Route::patch('/elements/{id}', [ElementController::class, 'update']) + ->middleware(AuthMiddleware::class); diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index 132a526..9c08e1c 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -33,6 +33,14 @@ class FakeElementRepository implements ElementRepository return $element; } + public function update(Element $element): Element + { + $updatedElement = $this->cloneElement($element); + $this->elementsById[$updatedElement->getId()] = $updatedElement; + + return $this->cloneElement($updatedElement); + } + public function find(int $id): ?Element { if (! isset($this->elementsById[$id])) { diff --git a/backend/tests/Feature/CorsTest.php b/backend/tests/Feature/CorsTest.php index b5ab81d..52273e4 100644 --- a/backend/tests/Feature/CorsTest.php +++ b/backend/tests/Feature/CorsTest.php @@ -21,4 +21,23 @@ class CorsTest extends TestCase ); $response->assertHeader('Access-Control-Allow-Credentials', 'true'); } + + public function testAllowsPatchPreflight(): void + { + $response = $this->withHeaders([ + 'Origin' => 'http://localhost:5173', + 'Access-Control-Request-Method' => 'PATCH', + 'Access-Control-Request-Headers' => 'content-type', + ])->options('/api/elements/1'); + + $response->assertNoContent(); + $response->assertHeader( + 'Access-Control-Allow-Origin', + 'http://localhost:5173' + ); + $this->assertStringContainsString( + 'PATCH', + $response->headers->get('Access-Control-Allow-Methods') + ); + } } diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 33725c8..2ff9089 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -2,10 +2,17 @@ namespace Tests\Feature; +use App\Auth\CreateSessionDto; +use App\Auth\SessionRepository; use App\Element\CreateElementDto; use App\Element\ElementRepository; use App\Set\CreateSetDto; use App\Set\SetRepository; +use App\Shared\ValueObject\EmailAddress; +use App\User\CreateUserDto; +use App\User\UserRepository; +use DateTimeImmutable; +use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -92,4 +99,116 @@ class ElementsEndpointTest extends TestCase 'error' => 'Element not found', ]); } + + public function testUpdateRequiresAuthentication(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + richText: '
A structured path for growth
', + pdfPath: '/assets/pdfs/baderech.pdf', + youtubeUrl: null, + parentElement: null, + )); + + $response = $this->patchJson("/api/elements/{$element->getId()}", [ + 'title' => 'Updated title', + 'description' => 'Updated description', + 'iconImageUrl' => '/assets/updated-icon.png', + 'richText' => 'Updated rich text
', + 'pdfPath' => '/assets/pdfs/updated.pdf', + 'youtubeUrl' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ]); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedUpdateReturnsElementPayload(): void + { + $sampleYoutubeUrl = 'https://www.youtube.com/watch?v=' + . 'dQw4w9WgXcQ'; + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + richText: 'A structured path for growth
', + pdfPath: '/assets/pdfs/baderech.pdf', + youtubeUrl: null, + parentElement: null, + )); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->patchJson("/api/elements/{$element->getId()}", [ + 'title' => 'Updated title', + 'description' => 'Updated description', + 'iconImageUrl' => '/assets/updated-icon.png', + 'richText' => 'Updated rich text
', + 'pdfPath' => '/assets/pdfs/updated.pdf', + 'youtubeUrl' => $sampleYoutubeUrl, + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'element' => [ + 'id' => $element->getId(), + 'title' => 'Updated title', + 'description' => 'Updated description', + 'iconImageUrl' => '/assets/updated-icon.png', + 'richText' => 'Updated rich text
', + 'pdfPath' => '/assets/pdfs/updated.pdf', + 'youtubeUrl' => $sampleYoutubeUrl, + ], + ]); + $updatedElement = $elementRepository->find($element->getId()); + $this->assertSame('Updated title', $updatedElement->getTitle()); + $this->assertSame( + 'Updated rich text
', + $updatedElement->getRichText(), + ); + } + + private function createSession(string $token): void + { + $userRepository = app(UserRepository::class); + $sessionRepository = app(SessionRepository::class); + $user = $userRepository->create(new CreateUserDto( + email: new EmailAddress('admin@example.com'), + passwordHash: 'password-hash', + )); + $now = new DateTimeImmutable( + '2026-05-01T00:00:00', + new DateTimeZone('UTC'), + ); + $sessionRepository->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $now, + expiresAt: new DateTimeImmutable( + '2099-01-01T00:00:00', + new DateTimeZone('UTC'), + ), + )); + } } diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index d06c0f7..99bfa3e 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -6,6 +6,13 @@ use App\Controllers\ElementController; use App\Element\CreateElementDto; use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; +use App\Element\UseCases\UpdateElement\UpdateDescription; +use App\Element\UseCases\UpdateElement\UpdateElement; +use App\Element\UseCases\UpdateElement\UpdateIconImageUrl; +use App\Element\UseCases\UpdateElement\UpdatePdfPath; +use App\Element\UseCases\UpdateElement\UpdateRichText; +use App\Element\UseCases\UpdateElement\UpdateTitle; +use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl; use App\Set\Set as DomainSet; use Tests\Fakes\FakeElementRepository; use Tests\TestCase; @@ -20,7 +27,16 @@ class ElementControllerTest extends TestCase { $this->elementRepo = new FakeElementRepository(); $getElement = new GetElement($this->elementRepo); - $this->controller = new ElementController($getElement); + $updateElement = new UpdateElement( + new UpdateTitle($this->elementRepo), + new UpdateDescription($this->elementRepo), + new UpdateIconImageUrl($this->elementRepo), + new UpdateRichText($this->elementRepo), + new UpdatePdfPath($this->elementRepo), + new UpdateYoutubeUrl($this->elementRepo), + $this->elementRepo, + ); + $this->controller = new ElementController($getElement, $updateElement); } public function testShowReturnsElementPayload(): void diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php index 16c08a7..89e9c84 100644 --- a/backend/tests/Unit/Element/ElementTest.php +++ b/backend/tests/Unit/Element/ElementTest.php @@ -68,4 +68,39 @@ class ElementTest extends TestCase $this->assertNull($rootElement->getYoutubeUrl()); $this->assertNull($rootElement->getParentElement()); } + + public function testUpdatesEditableFields(): void + { + $set = new DomainSet( + id: 1, + name: 'Daily learning', + description: 'Daily learning description', + iconImageUrl: '/assets/daily-learning-icon.svg', + ); + $element = new Element( + id: 1, + title: 'Original', + description: 'Original description', + iconImageUrl: '/assets/original.svg', + richText: 'Original
', + pdfPath: '/assets/pdfs/original.pdf', + youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU', + set: $set, + parentElement: null, + ); + + $element->setTitle('Updated'); + $element->setDescription('Updated description'); + $element->setIconImageUrl(null); + $element->setRichText('Updated
'); + $element->setPdfPath(null); + $element->setYoutubeUrl(null); + + $this->assertSame('Updated', $element->getTitle()); + $this->assertSame('Updated description', $element->getDescription()); + $this->assertNull($element->getIconImageUrl()); + $this->assertSame('Updated
', $element->getRichText()); + $this->assertNull($element->getPdfPath()); + $this->assertNull($element->getYoutubeUrl()); + } } diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php new file mode 100644 index 0000000..7c27c9c --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php @@ -0,0 +1,175 @@ +elementRepo = new FakeElementRepository(); + } + + public function testUpdateTitleUpdatesOnlyTitle(): void + { + $element = $this->createFilledElement(); + $updateTitle = new UpdateTitle($this->elementRepo); + + $updatedElement = $updateTitle->execute(new UpdateTitleRequest( + id: $element->getId(), + title: 'Updated title', + )); + + $this->assertSame('Updated title', $updatedElement->getTitle()); + $this->assertSame( + $element->getDescription(), + $updatedElement->getDescription(), + ); + $this->assertSame( + $element->getRichText(), + $updatedElement->getRichText(), + ); + } + + public function testUpdateTitleRejectsBlankTitle(): void + { + $this->createFilledElement(); + $updateTitle = new UpdateTitle($this->elementRepo); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('title is required'); + + $updateTitle->execute(new UpdateTitleRequest(id: 1, title: '')); + } + + public function testUpdateDescriptionUpdatesOnlyDescription(): void + { + $element = $this->createFilledElement(); + $updateDescription = new UpdateDescription($this->elementRepo); + + $updatedElement = $updateDescription->execute( + new UpdateDescriptionRequest( + id: $element->getId(), + description: 'Updated description', + ) + ); + + $this->assertSame( + 'Updated description', + $updatedElement->getDescription(), + ); + $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + $this->assertSame( + $element->getYoutubeUrl(), + $updatedElement->getYoutubeUrl(), + ); + } + + public function testUpdateIconImageUrlClearsEmptyString(): void + { + $element = $this->createFilledElement(); + $updateIconImageUrl = new UpdateIconImageUrl($this->elementRepo); + + $updatedElement = $updateIconImageUrl->execute( + new UpdateIconImageUrlRequest( + id: $element->getId(), + iconImageUrl: '', + ) + ); + + $this->assertNull($updatedElement->getIconImageUrl()); + $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + } + + public function testUpdateRichTextUpdatesOnlyRichText(): void + { + $element = $this->createFilledElement(); + $updateRichText = new UpdateRichText($this->elementRepo); + + $updatedElement = $updateRichText->execute( + new UpdateRichTextRequest( + id: $element->getId(), + richText: 'Updated rich text
', + ) + ); + + $this->assertSame( + 'Updated rich text
', + $updatedElement->getRichText(), + ); + $this->assertSame( + $element->getDescription(), + $updatedElement->getDescription(), + ); + } + + public function testUpdatePdfPathClearsEmptyString(): void + { + $element = $this->createFilledElement(); + $updatePdfPath = new UpdatePdfPath($this->elementRepo); + + $updatedElement = $updatePdfPath->execute( + new UpdatePdfPathRequest(id: $element->getId(), pdfPath: '') + ); + + $this->assertNull($updatedElement->getPdfPath()); + $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + } + + public function testUpdateYoutubeUrlClearsEmptyString(): void + { + $element = $this->createFilledElement(); + $updateYoutubeUrl = new UpdateYoutubeUrl($this->elementRepo); + + $updatedElement = $updateYoutubeUrl->execute( + new UpdateYoutubeUrlRequest( + id: $element->getId(), + youtubeUrl: '', + ) + ); + + $this->assertNull($updatedElement->getYoutubeUrl()); + $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + } + + private function createFilledElement(): Element + { + $set = new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + + return $this->elementRepo->create(new CreateElementDto( + set: $set, + title: 'Original title', + description: 'Original description', + iconImageUrl: '/assets/original-icon.png', + richText: 'Original rich text
', + pdfPath: '/assets/pdfs/original.pdf', + youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU', + parentElement: null, + )); + } +} diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementTest.php new file mode 100644 index 0000000..0ce0d88 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdateElementTest.php @@ -0,0 +1,270 @@ +elementRepo = new FakeElementRepository(); + $this->updateElement = new UpdateElement( + new UpdateTitle($this->elementRepo), + new UpdateDescription($this->elementRepo), + new UpdateIconImageUrl($this->elementRepo), + new UpdateRichText($this->elementRepo), + new UpdatePdfPath($this->elementRepo), + new UpdateYoutubeUrl($this->elementRepo), + $this->elementRepo, + ); + } + + public function testUpdatesContentFields(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Original title', + 'Original description', + '/assets/original-icon.png', + 'Original rich text
', + '/assets/pdfs/original.pdf', + 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', + null, + ); + + $updatedElement = $this->updateElement->execute( + new UpdateElementRequest( + id: $element->getId(), + title: 'Updated title', + description: 'Updated description', + iconImageUrl: '/assets/updated-icon.png', + richText: 'Updated rich text
', + pdfPath: '/assets/pdfs/updated.pdf', + youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ) + ); + + $this->assertSame($element->getId(), $updatedElement->getId()); + $this->assertSame('Updated title', $updatedElement->getTitle()); + $this->assertSame( + 'Updated description', + $updatedElement->getDescription(), + ); + $this->assertSame( + '/assets/updated-icon.png', + $updatedElement->getIconImageUrl(), + ); + $this->assertSame( + 'Updated rich text
', + $updatedElement->getRichText(), + ); + $this->assertSame( + '/assets/pdfs/updated.pdf', + $updatedElement->getPdfPath(), + ); + $this->assertSame( + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + $updatedElement->getYoutubeUrl(), + ); + + $persistedElement = $this->elementRepo->find($element->getId()); + $this->assertInstanceOf(Element::class, $persistedElement); + $this->assertSame('Updated title', $persistedElement->getTitle()); + } + + public function testConvertsEmptyNullableFieldsToNull(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Original title', + 'Original description', + '/assets/original-icon.png', + 'Original rich text
', + '/assets/pdfs/original.pdf', + 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', + null, + ); + + $updatedElement = $this->updateElement->execute( + new UpdateElementRequest( + id: $element->getId(), + title: 'Updated title', + description: 'Updated description', + iconImageUrl: '', + richText: 'Updated rich text
', + pdfPath: '', + youtubeUrl: '', + ) + ); + + $this->assertNull($updatedElement->getIconImageUrl()); + $this->assertNull($updatedElement->getPdfPath()); + $this->assertNull($updatedElement->getYoutubeUrl()); + } + + public function testUpdatesOnlySuppliedFields(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Original title', + 'Original description', + '/assets/original-icon.png', + 'Original rich text
', + '/assets/pdfs/original.pdf', + 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', + null, + ); + + $updatedElement = $this->updateElement->execute( + new UpdateElementRequest( + id: $element->getId(), + title: 'Updated title', + description: null, + iconImageUrl: null, + richText: null, + pdfPath: null, + youtubeUrl: null, + ) + ); + + $this->assertSame('Updated title', $updatedElement->getTitle()); + $this->assertSame( + 'Original description', + $updatedElement->getDescription(), + ); + $this->assertSame( + '/assets/original-icon.png', + $updatedElement->getIconImageUrl(), + ); + $this->assertSame( + 'Original rich text
', + $updatedElement->getRichText(), + ); + $this->assertSame( + '/assets/pdfs/original.pdf', + $updatedElement->getPdfPath(), + ); + $this->assertSame( + 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', + $updatedElement->getYoutubeUrl(), + ); + } + + public function testThrowsWhenIdMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('id is required'); + + $this->updateElement->execute(new UpdateElementRequest( + id: null, + title: 'Updated title', + description: 'Updated description', + iconImageUrl: null, + richText: 'Updated rich text
', + pdfPath: null, + youtubeUrl: null, + )); + } + + public function testThrowsWhenNothingProvided(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('nothing to update'); + + $this->updateElement->execute(new UpdateElementRequest( + id: 1, + title: null, + description: null, + iconImageUrl: null, + richText: null, + pdfPath: null, + youtubeUrl: null, + )); + } + + public function testThrowsWhenTitleIsBlank(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('title is required'); + + $this->updateElement->execute(new UpdateElementRequest( + id: 1, + title: '', + description: 'Updated description', + iconImageUrl: null, + richText: 'Updated rich text
', + pdfPath: null, + youtubeUrl: null, + )); + } + + public function testThrowsWhenElementDoesNotExist(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Element not found'); + + $this->updateElement->execute(new UpdateElementRequest( + id: 999, + title: 'Updated title', + description: 'Updated description', + iconImageUrl: null, + richText: 'Updated rich text
', + pdfPath: null, + youtubeUrl: null, + )); + } + + private function createSet(int $id, string $name): DomainSet + { + return new DomainSet( + id: $id, + name: $name, + description: "$name description", + iconImageUrl: '/assets/baderech-icon.png', + ); + } + + private function createElement( + DomainSet $set, + string $title, + string $description, + ?string $iconImageUrl, + string $richText, + ?string $pdfPath, + ?string $youtubeUrl, + ?Element $parentElement, + ): Element { + return $this->elementRepo->create(new CreateElementDto( + set: $set, + title: $title, + description: $description, + iconImageUrl: $iconImageUrl, + richText: $richText, + pdfPath: $pdfPath, + youtubeUrl: $youtubeUrl, + parentElement: $parentElement, + )); + } +} diff --git a/frontend/rabbi_gerzi/cypress.config.mjs b/frontend/rabbi_gerzi/cypress.config.mjs deleted file mode 100644 index d215a17..0000000 --- a/frontend/rabbi_gerzi/cypress.config.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'cypress' -import createBundler from '@bahmutov/cypress-esbuild-preprocessor' - -export default defineConfig({ - e2e: { - baseUrl: 'http://localhost:5173', - specPattern: 'cypress/e2e/**/*.cy.ts', - supportFile: 'cypress/support/e2e.ts', - fixturesFolder: false, - video: false, - screenshotOnRunFailure: false, - setupNodeEvents(onEvent) { - onEvent('file:preprocessor', createBundler()) - }, - }, -}) diff --git a/frontend/rabbi_gerzi/cypress.config.ts b/frontend/rabbi_gerzi/cypress.config.ts new file mode 100644 index 0000000..82a9470 --- /dev/null +++ b/frontend/rabbi_gerzi/cypress.config.ts @@ -0,0 +1,97 @@ +import { execSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'cypress' +import createBundler from '@bahmutov/cypress-esbuild-preprocessor' +import { Client } from 'pg' + +const configDirectory = dirname(fileURLToPath(import.meta.url)) +const backendDirectory = resolve(configDirectory, '../../backend') +const socketDirectory = resolve(configDirectory, '../../.postgres') +const appDatabase = 'postgres' +const templateDatabase = 'postgres_cypress_template' + +function controlClient(): Client { + return new Client({ + host: socketDirectory, + database: 'template1', + user: 'postgres', + }) +} + +async function buildTemplate(): PromiseUpdated admin rich text
' + + loginAsAdmin() + cy.visit('/admin/element/1') + + fillElementForm( + updatedTitle, + updatedDescription, + originalIconImageUrl, + updatedRichText, + '', + '', + ) + cy.get('[data-cy="admin-element-save"]').click() + + cy.get('[data-cy="admin-element-status"]') + .should('be.visible') + .and('contain.text', 'Saved') + cy.contains('header.site-header a', 'View Element').click() + cy.location('pathname').should('eq', '/element/1') + cy.contains('h1', updatedTitle).should('be.visible') + cy.get('[data-cy="element-rich-text"]') + .should('contain.text', 'Updated admin rich text') + + cy.visit('/admin/element/1') + fillElementForm( + originalTitle, + originalDescription, + originalIconImageUrl, + '', + '', + '', + ) + cy.get('[data-cy="admin-element-save"]').click() + cy.get('[data-cy="admin-element-status"]') + .should('be.visible') + .and('contain.text', 'Saved') + }) +}) diff --git a/frontend/rabbi_gerzi/cypress/e2e/database-reset.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/database-reset.cy.ts new file mode 100644 index 0000000..dd8ec44 --- /dev/null +++ b/frontend/rabbi_gerzi/cypress/e2e/database-reset.cy.ts @@ -0,0 +1,41 @@ +function loginAsAdmin(): void { + cy.visit('/login') + + cy.get('[data-cy="login-email"]').type('admin@rabbigerzi.test') + cy.get('[data-cy="login-password"]').type('password123!@#') + cy.get('[data-cy="login-submit"]').click() + + cy.url().should('eq', Cypress.config().baseUrl + '/') + cy.getCookie('auth_token').should('exist') +} + +describe('cypress database reset', () => { + it('can dirty element seed data', () => { + loginAsAdmin() + + cy.visit('/admin/element/1') + cy.get('[data-cy="admin-element-title"]') + .clear() + .type('Dirty Cypress Element') + cy.get('[data-cy="admin-element-description"]') + .clear() + .type('Dirty Cypress description') + cy.get('[data-cy="admin-element-rich-text"]') + .clear() + .type('Dirty Cypress rich text
', { + parseSpecialCharSequences: false, + }) + cy.get('[data-cy="admin-element-save"]').click() + + cy.get('[data-cy="admin-element-status"]') + .should('be.visible') + .and('contain.text', 'Saved') + }) + + it('starts the next test from seeded element data', () => { + cy.visit('/element/1') + + cy.contains('h1', 'Baderech HaAvodah').should('be.visible') + cy.get('[data-cy="element-rich-text"]').should('not.exist') + }) +}) diff --git a/frontend/rabbi_gerzi/cypress/support/commands.ts b/frontend/rabbi_gerzi/cypress/support/commands.ts new file mode 100644 index 0000000..65696c1 --- /dev/null +++ b/frontend/rabbi_gerzi/cypress/support/commands.ts @@ -0,0 +1,16 @@ +///