create(new CreateSetDto( name: 'Baderech HaAvodah', description: 'Baderech HaAvodah is a way of living', iconImageUrl: '/assets/baderech-haavodah-icon.png', )); $dailyLearningSet = $setRepository->create(new CreateSetDto( name: 'Daily Learning', description: 'Daily learning for steady growth', iconImageUrl: '/assets/daily-learning-icon.svg', )); $baderechRootElement = $elementRepository->create( new CreateElementDto( set: $baderechSet, title: $baderechSet->getName(), description: $baderechSet->getDescription(), iconImageUrl: null, richText: '', shortPdfPath: null, longPdfPath: null, youtubeUrl: null, parentElement: null, ) ); $response = $this->getJson('/api/sets'); $response->assertOk(); $response->assertExactJson([ 'sets' => [ [ 'id' => $baderechSet->getId(), 'name' => $baderechSet->getName(), 'description' => $baderechSet->getDescription(), 'iconImageUrl' => $baderechSet->getIconImageUrl(), 'rootElementId' => $baderechRootElement->getId(), ], [ 'id' => $dailyLearningSet->getId(), 'name' => $dailyLearningSet->getName(), 'description' => $dailyLearningSet->getDescription(), 'iconImageUrl' => $dailyLearningSet->getIconImageUrl(), 'rootElementId' => null, ], ], ]); } public function testCreateSetRequiresAuthentication(): void { $response = $this->postJson('/api/sets', [ 'name' => 'New Series', 'description' => 'A new learning series', ]); $response->assertUnauthorized(); $response->assertExactJson([ 'error' => 'unauthenticated', ]); } public function testAuthenticatedCreateSetCreatesRootElement(): void { Storage::fake('public'); $setRepository = app(SetRepository::class); $elementRepository = app(ElementRepository::class); $this->createSession('valid-token'); $response = $this->withCredentials() ->withUnencryptedCookie('auth_token', 'valid-token') ->post('/api/sets', [ 'name' => 'New Series', 'description' => 'A new learning series', 'iconImage' => $this->iconImageUpload(), ]); $response->assertCreated(); $body = $response->json(); $setId = $body['set']['id']; $rootElementId = $body['set']['rootElementId']; $this->assertIsInt($setId); $this->assertIsInt($rootElementId); $this->assertSame('New Series', $body['set']['name']); $this->assertSame( 'A new learning series', $body['set']['description'], ); $this->assertStringContainsString( '/storage/set-icons/', $body['set']['iconImageUrl'], ); $createdSet = $setRepository->find($setId); $this->assertNotNull($createdSet); $rootElement = $elementRepository->findRootBySet($createdSet); $this->assertNotNull($rootElement); $this->assertSame($rootElementId, $rootElement->getId()); $this->assertSame('New Series', $rootElement->getTitle()); $this->assertSame( 'A new learning series', $rootElement->getDescription(), ); $storedIconPath = $createdSet->getIconImageUrl(); $this->assertStringStartsWith('set-icons/', $storedIconPath); $this->assertSame($storedIconPath, $rootElement->getIconImageUrl()); Storage::disk('public')->assertExists($storedIconPath); $elementResponse = $this->getJson("/api/elements/$rootElementId"); $elementResponse->assertOk(); $elementResponse->assertJsonPath( 'element.iconImageUrl', $body['set']['iconImageUrl'], ); } public function testUpdateSetRequiresAuthentication(): void { $setRepository = app(SetRepository::class); $set = $setRepository->create(new CreateSetDto( name: 'Original Set', description: 'Original set description', iconImageUrl: '/assets/original-icon.png', )); $response = $this->postJson( "/api/sets/{$set->getId()}/update", [ 'name' => 'Updated Set', 'description' => 'Updated set description', ], ); $response->assertUnauthorized(); $response->assertExactJson([ 'error' => 'unauthenticated', ]); } public function testAuthenticatedUpdateSetUpdatesMediaPayloadOnly(): void { $setRepository = app(SetRepository::class); $elementRepository = app(ElementRepository::class); $this->createSession('valid-token'); $set = $setRepository->create(new CreateSetDto( name: 'Original Set', description: 'Original set description', iconImageUrl: '/assets/original-icon.png', )); $rootElement = $elementRepository->create(new CreateElementDto( set: $set, title: 'Original Root', description: 'Original root description', iconImageUrl: '/assets/original-icon.png', richText: '', shortPdfPath: null, longPdfPath: null, youtubeUrl: null, parentElement: null, )); $response = $this->withCredentials() ->withUnencryptedCookie('auth_token', 'valid-token') ->postJson("/api/sets/{$set->getId()}/update", [ 'name' => 'Updated Set', 'description' => 'Updated set description', ]); $response->assertOk(); $response->assertExactJson([ 'set' => [ 'id' => $set->getId(), 'name' => 'Updated Set', 'description' => 'Updated set description', 'iconImageUrl' => '/assets/original-icon.png', 'rootElementId' => $rootElement->getId(), ], ]); $updatedSet = $setRepository->find($set->getId()); $this->assertNotNull($updatedSet); $this->assertSame('Updated Set', $updatedSet->getName()); $this->assertSame( 'Updated set description', $updatedSet->getDescription(), ); $unchangedRootElement = $elementRepository->find( $rootElement->getId(), ); $this->assertNotNull($unchangedRootElement); $this->assertSame('Original Root', $unchangedRootElement->getTitle()); $this->assertSame( 'Original root description', $unchangedRootElement->getDescription(), ); } public function testDeleteSetRequiresAuthentication(): void { $setRepository = app(SetRepository::class); $set = $setRepository->create(new CreateSetDto( name: 'Set to delete', description: 'Set deletion description', iconImageUrl: '/assets/delete-icon.png', )); $response = $this->deleteJson("/api/sets/{$set->getId()}"); $response->assertUnauthorized(); $response->assertExactJson([ 'error' => 'unauthenticated', ]); } public function testAuthenticatedDeleteSetRemovesSetAndElements(): void { $setRepository = app(SetRepository::class); $elementRepository = app(ElementRepository::class); $this->createSession('valid-token'); $set = $setRepository->create(new CreateSetDto( name: 'Set to delete', description: 'Set deletion description', iconImageUrl: 'set-icons/delete-icon.png', )); $rootElement = $elementRepository->create(new CreateElementDto( set: $set, title: 'Root to delete', description: 'Root deletion description', iconImageUrl: 'set-icons/delete-icon.png', richText: '', shortPdfPath: null, longPdfPath: null, youtubeUrl: null, parentElement: null, )); $childElement = $elementRepository->create(new CreateElementDto( set: $set, title: 'Child to delete', description: 'Child deletion description', iconImageUrl: 'element-icons/delete-child.png', richText: '', shortPdfPath: 'element-pdfs/short/delete-child.pdf', longPdfPath: 'element-pdfs/long/delete-child.pdf', youtubeUrl: null, parentElement: $rootElement, )); $response = $this->withCredentials() ->withUnencryptedCookie('auth_token', 'valid-token') ->deleteJson("/api/sets/{$set->getId()}"); $response->assertNoContent(); $this->assertNull($setRepository->find($set->getId())); $this->assertNull($elementRepository->find($rootElement->getId())); $this->assertNull($elementRepository->find($childElement->getId())); $setsResponse = $this->getJson('/api/sets'); $setsResponse->assertOk(); $setsResponse->assertJsonMissing([ 'id' => $set->getId(), ]); } public function testCreateSetRequiresIconImage(): void { $this->createSession('valid-token'); $response = $this->withCredentials() ->withUnencryptedCookie('auth_token', 'valid-token') ->post('/api/sets', [ 'name' => 'New Series', 'description' => 'A new learning series', ]); $response->assertBadRequest(); $response->assertExactJson([ 'error' => 'icon image is required', ]); } 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'), ), )); } private function iconImageUpload(): UploadedFile { $fixturePath = __DIR__ . '/../fixtures/icon.png'; return new UploadedFile( $fixturePath, 'icon.png', 'image/png', null, true, ); } }