diff --git a/ai/backend-context.md b/ai/backend-context.md index 9214acd..83c2081 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -50,6 +50,9 @@ intentionally unclaimed; the built-in health endpoint is `/up`. through additional repositories. - Test use-case branches at the use-case seam. Do not repeat every branch in controller or HTTP tests. +- Keep deletion behavior tests in the relevant deletion use-case suite. Do + not test deletion policy through entity, repository, or database-constraint + tests. - Plain entities, value objects, use cases, middleware, and controller units should extend `PHPUnit\Framework\TestCase` when they do not need Laravel. - Extend `Tests\TestCase` only when a test needs Laravel's container, facades, diff --git a/backend/app/Http/Controllers/SetController.php b/backend/app/Http/Controllers/SetController.php new file mode 100644 index 0000000..e432739 --- /dev/null +++ b/backend/app/Http/Controllers/SetController.php @@ -0,0 +1,26 @@ + $set->getId(), + 'name' => $set->getName(), + ]; + }, $this->listSets->execute()); + + return new JsonResponse(['sets' => $sets]); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 2255353..c9490ba 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -16,6 +16,8 @@ use App\Email\Emailer; use App\Email\EmailFactory; use App\Email\LaravelEmailer; use App\Email\LaravelEmailFactory; +use App\Set\EloquentSetRepository; +use App\Set\SetRepository; use App\User\EloquentUserRepository; use App\User\UserRepository; use Carbon\CarbonImmutable; @@ -45,6 +47,10 @@ class AppServiceProvider extends ServiceProvider ); $this->app->bind(Emailer::class, LaravelEmailer::class); $this->app->bind(EmailFactory::class, LaravelEmailFactory::class); + $this->app->bind( + SetRepository::class, + EloquentSetRepository::class, + ); $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(Clock::class, SystemClock::class); diff --git a/backend/app/Set/CreateSetDto.php b/backend/app/Set/CreateSetDto.php new file mode 100644 index 0000000..ac7b460 --- /dev/null +++ b/backend/app/Set/CreateSetDto.php @@ -0,0 +1,13 @@ + $dto->name, + 'creator_id' => $dto->creator->getId(), + ]); + + return new Set( + id: $model->id, + name: $model->name, + creator: $dto->creator, + ); + } + + public function all(): array + { + $models = SetModel::query() + ->orderBy('name') + ->orderBy('id') + ->get(); + $sets = []; + + foreach ($models as $model) { + $sets[] = $this->toDomain($model); + } + + return $sets; + } + + private function toDomain(SetModel $model): Set + { + $creator = $this->userRepository->find($model->creator_id); + if ($creator === null) { + throw new RuntimeException('set creator not found'); + } + + return new Set( + id: $model->id, + name: $model->name, + creator: $creator, + ); + } +} diff --git a/backend/app/Set/Set.php b/backend/app/Set/Set.php new file mode 100644 index 0000000..064e475 --- /dev/null +++ b/backend/app/Set/Set.php @@ -0,0 +1,29 @@ +id; + } + + public function getName(): string + { + return $this->name; + } + + public function getCreator(): User + { + return $this->creator; + } +} diff --git a/backend/app/Set/SetModel.php b/backend/app/Set/SetModel.php new file mode 100644 index 0000000..021b3a5 --- /dev/null +++ b/backend/app/Set/SetModel.php @@ -0,0 +1,26 @@ +|SetModel newModelQuery() + * @method static Builder|SetModel newQuery() + * @method static Builder|SetModel query() + * + * @mixin \Eloquent + */ +#[Fillable(['name', 'creator_id'])] +class SetModel extends Model +{ + protected $table = 'sets'; + + public $timestamps = false; +} diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php new file mode 100644 index 0000000..0545ab1 --- /dev/null +++ b/backend/app/Set/SetRepository.php @@ -0,0 +1,13 @@ + + */ + public function all(): array; +} diff --git a/backend/app/Set/UseCases/ListSets/ListSets.php b/backend/app/Set/UseCases/ListSets/ListSets.php new file mode 100644 index 0000000..d4e80df --- /dev/null +++ b/backend/app/Set/UseCases/ListSets/ListSets.php @@ -0,0 +1,21 @@ + + */ + public function execute(): array + { + return $this->setRepository->all(); + } +} diff --git a/backend/database/migrations/2026_08_03_000000_create_sets_table.php b/backend/database/migrations/2026_08_03_000000_create_sets_table.php new file mode 100644 index 0000000..589609b --- /dev/null +++ b/backend/database/migrations/2026_08_03_000000_create_sets_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name')->unique(); + $table->foreignId('creator_id') + ->constrained('users') + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sets'); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index def224c..31a5820 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -12,5 +12,6 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call(UserSeeder::class); + $this->call(SetSeeder::class); } } diff --git a/backend/database/seeders/SetSeeder.php b/backend/database/seeders/SetSeeder.php new file mode 100644 index 0000000..d1ecf95 --- /dev/null +++ b/backend/database/seeders/SetSeeder.php @@ -0,0 +1,45 @@ +findByEmail( + new EmailAddress(UserSeeder::EMAIL), + ); + if ($user === null) { + throw new RuntimeException('seeded user not found'); + } + + $repository = app(SetRepository::class); + $existingNames = array_map(function ($set): string { + return $set->getName(); + }, $repository->all()); + + foreach (self::NAMES as $name) { + if (in_array($name, $existingNames, true)) { + continue; + } + + $repository->create(new CreateSetDto( + name: $name, + creator: $user, + )); + } + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index d471fcf..62384a9 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@ group(function (): void { Route::get('/me', [AuthController::class, 'me']); + Route::get('/sets', [SetController::class, 'index']); Route::post('/logout', [AuthController::class, 'logout']); }); diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php new file mode 100644 index 0000000..a4cff4c --- /dev/null +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -0,0 +1,49 @@ + + */ + private array $sets = []; + + public function create(CreateSetDto $dto): Set + { + $id = count($this->sets) + 1; + $set = new Set( + id: $id, + name: $dto->name, + creator: $dto->creator, + ); + $this->sets[$id] = $set; + + return $this->copy($set); + } + + public function all(): array + { + $sets = array_values($this->sets); + usort($sets, function (Set $first, Set $second): int { + return $first->getName() <=> $second->getName(); + }); + + return array_map(function (Set $set): Set { + return $this->copy($set); + }, $sets); + } + + private function copy(Set $set): Set + { + return new Set( + id: $set->getId(), + name: $set->getName(), + creator: $set->getCreator(), + ); + } +} diff --git a/backend/tests/Feature/Database/DatabaseSeederTest.php b/backend/tests/Feature/Database/DatabaseSeederTest.php index ca1ec91..45a7e51 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -3,8 +3,10 @@ namespace Tests\Feature\Database; use App\Auth\PasswordHasher; +use App\Set\SetRepository; use App\Shared\ValueObject\EmailAddress; use App\User\UserRepository; +use Database\Seeders\UserSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -36,4 +38,26 @@ class DatabaseSeederTest extends TestCase $user->getPasswordHash(), )); } + + public function test_it_seeds_the_available_sets_idempotently(): void + { + $this->seed(); + $this->seed(); + + $sets = app(SetRepository::class)->all(); + + $this->assertDatabaseCount('sets', 3); + $this->assertSame( + ['Bible', 'Course', 'Fitness Program'], + array_map(function ($set): string { + return $set->getName(); + }, $sets), + ); + foreach ($sets as $set) { + $this->assertSame( + UserSeeder::EMAIL, + $set->getCreator()->getEmail()->value(), + ); + } + } } diff --git a/backend/tests/Feature/Set/EloquentSetRepositoryTest.php b/backend/tests/Feature/Set/EloquentSetRepositoryTest.php new file mode 100644 index 0000000..820f0b9 --- /dev/null +++ b/backend/tests/Feature/Set/EloquentSetRepositoryTest.php @@ -0,0 +1,93 @@ +createUser('creator@example.com'); + $set = app(SetRepository::class)->create(new CreateSetDto( + name: 'Bible', + creator: $creator, + )); + + $this->assertGreaterThan(0, $set->getId()); + $this->assertSame('Bible', $set->getName()); + $this->assertSame($creator->getId(), $set->getCreator()->getId()); + $this->assertDatabaseHas('sets', [ + 'id' => $set->getId(), + 'name' => 'Bible', + 'creator_id' => $creator->getId(), + ]); + } + + public function test_it_lists_every_set_alphabetically(): void + { + $firstCreator = $this->createUser('first@example.com'); + $secondCreator = $this->createUser('second@example.com'); + $repository = app(SetRepository::class); + $repository->create(new CreateSetDto( + name: 'Fitness Program', + creator: $firstCreator, + )); + $repository->create(new CreateSetDto( + name: 'Bible', + creator: $secondCreator, + )); + $repository->create(new CreateSetDto( + name: 'Course', + creator: $firstCreator, + )); + + $sets = $repository->all(); + + $this->assertSame( + ['Bible', 'Course', 'Fitness Program'], + array_map(function ($set): string { + return $set->getName(); + }, $sets), + ); + $this->assertSame( + $secondCreator->getId(), + $sets[0]->getCreator()->getId(), + ); + } + + public function test_it_rejects_duplicate_set_names(): void + { + $creator = $this->createUser('creator@example.com'); + $repository = app(SetRepository::class); + $repository->create(new CreateSetDto( + name: 'Bible', + creator: $creator, + )); + + $this->expectException(QueryException::class); + + $repository->create(new CreateSetDto( + name: 'Bible', + creator: $creator, + )); + } + + private function createUser(string $email): User + { + return app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress($email), + passwordHash: 'hashed-password', + )); + } +} diff --git a/backend/tests/Feature/Set/ListSetsEndpointTest.php b/backend/tests/Feature/Set/ListSetsEndpointTest.php new file mode 100644 index 0000000..6f8085b --- /dev/null +++ b/backend/tests/Feature/Set/ListSetsEndpointTest.php @@ -0,0 +1,102 @@ +createUser('reader@example.com'); + $otherUser = $this->createUser('creator@example.com'); + $repository = app(SetRepository::class); + $course = $repository->create(new CreateSetDto( + name: 'Course', + creator: $authenticatedUser, + )); + $bible = $repository->create(new CreateSetDto( + name: 'Bible', + creator: $otherUser, + )); + $this->createSession($authenticatedUser); + + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/api/sets'); + + $response->assertOk()->assertExactJson([ + 'sets' => [ + [ + 'id' => $bible->getId(), + 'name' => 'Bible', + ], + [ + 'id' => $course->getId(), + 'name' => 'Course', + ], + ], + ]); + } + + public function test_it_returns_an_empty_catalog(): void + { + $authenticatedUser = $this->createUser('reader@example.com'); + $this->createSession($authenticatedUser); + + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/api/sets'); + + $response->assertOk()->assertExactJson(['sets' => []]); + } + + public function test_it_rejects_an_unauthenticated_request(): void + { + $response = $this->getJson('/api/sets'); + + $response + ->assertStatus(401) + ->assertExactJson(['error' => 'unauthenticated']); + } + + private function createUser(string $email): User + { + return app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress($email), + passwordHash: 'hashed-password', + )); + } + + private function createSession(User $user): void + { + $createdAt = new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + ); + app(SessionRepository::class)->create(new CreateSessionDto( + token: 'valid-token', + user: $user, + createdAt: $createdAt, + expiresAt: $createdAt->modify('+7 days'), + )); + } +} diff --git a/backend/tests/Unit/Set/SetTest.php b/backend/tests/Unit/Set/SetTest.php new file mode 100644 index 0000000..ed7d75d --- /dev/null +++ b/backend/tests/Unit/Set/SetTest.php @@ -0,0 +1,29 @@ +assertSame(42, $set->getId()); + $this->assertSame('Bible', $set->getName()); + $this->assertSame($creator, $set->getCreator()); + } +} diff --git a/backend/tests/Unit/Set/UseCases/ListSetsTest.php b/backend/tests/Unit/Set/UseCases/ListSetsTest.php new file mode 100644 index 0000000..fbdf5fb --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/ListSetsTest.php @@ -0,0 +1,40 @@ +create(new CreateSetDto( + name: 'Course', + creator: $creator, + )); + $repository->create(new CreateSetDto( + name: 'Bible', + creator: $creator, + )); + + $sets = (new ListSets($repository))->execute(); + + $this->assertSame( + ['Bible', 'Course'], + array_map(function ($set): string { + return $set->getName(); + }, $sets), + ); + } +} diff --git a/frontend/website/cypress/e2e/confirm-email.cy.ts b/frontend/website/cypress/e2e/confirm-email.cy.ts index 075909d..deabca6 100644 --- a/frontend/website/cypress/e2e/confirm-email.cy.ts +++ b/frontend/website/cypress/e2e/confirm-email.cy.ts @@ -9,6 +9,10 @@ describe('email confirmation', () => { statusCode: 401, body: { error: 'unauthenticated' }, }).as('me') + cy.intercept('GET', '**/api/sets', { + statusCode: 200, + body: { sets: [] }, + }) }) it('chooses a password, confirms the account, and opens the dashboard', () => { @@ -31,7 +35,7 @@ describe('email confirmation', () => { cy.wait('@confirmEmail') cy.location('pathname').should('equal', '/dashboard') - cy.get('h1').should('have.text', 'Your next step starts here.') + cy.get('h1').should('have.text', 'Available sets') }) it('validates password length and confirmation before submitting', () => { diff --git a/frontend/website/cypress/e2e/session-auth.cy.ts b/frontend/website/cypress/e2e/session-auth.cy.ts index cd35dfd..defb942 100644 --- a/frontend/website/cypress/e2e/session-auth.cy.ts +++ b/frontend/website/cypress/e2e/session-auth.cy.ts @@ -38,6 +38,13 @@ function visitDashboardAndLogout(): void { } describe('session authentication', () => { + beforeEach(() => { + cy.intercept('GET', '**/api/sets', { + statusCode: 200, + body: { sets: [] }, + }) + }) + it('restores an authenticated session on a protected route', () => { cy.intercept('GET', '**/api/me', { statusCode: 200, @@ -48,7 +55,7 @@ describe('session authentication', () => { cy.wait('@me') cy.location('pathname').should('equal', '/dashboard') - cy.get('h1').should('have.text', 'Your next step starts here.') + cy.get('h1').should('have.text', 'Available sets') }) it('redirects an unauthenticated protected route to login', () => { diff --git a/frontend/website/cypress/e2e/sets-dashboard.cy.ts b/frontend/website/cypress/e2e/sets-dashboard.cy.ts new file mode 100644 index 0000000..3872b10 --- /dev/null +++ b/frontend/website/cypress/e2e/sets-dashboard.cy.ts @@ -0,0 +1,120 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +function interceptAuthenticatedUser(): void { + cy.intercept('GET', '**/api/me', { + statusCode: 200, + body: { user: authenticatedUser }, + }).as('me') +} + +describe('sets dashboard', () => { + beforeEach(() => { + interceptAuthenticatedUser() + }) + + it('shows every available set by name', () => { + cy.intercept('GET', '**/api/sets', (request) => { + expect(request.headers.accept).to.equal('application/json') + request.reply({ + statusCode: 200, + body: { + sets: [ + { id: 41, name: 'Bible' }, + { id: 58, name: 'Course' }, + { id: 92, name: 'Fitness Program' }, + ], + }, + }) + }).as('sets') + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@sets') + + cy.get('h1').should('have.text', 'Available sets') + cy.get('ul[aria-label="Available sets"] h2').then(($headings) => { + expect([...$headings].map((heading) => heading.textContent)).to.deep.equal([ + 'Bible', + 'Course', + 'Fitness Program', + ]) + }) + cy.get('ul[aria-label="Available sets"]') + .should('not.contain.text', '41') + .and('not.contain.text', '58') + .and('not.contain.text', '92') + .find('a, button') + .should('not.exist') + }) + + it('shows loading and empty catalog states', () => { + cy.intercept('GET', '**/api/sets', { + delay: 500, + statusCode: 200, + body: { sets: [] }, + }).as('sets') + + cy.visit('/dashboard') + cy.wait('@me') + cy.get('[role="status"]').should('have.text', 'Loading sets...') + cy.wait('@sets') + + cy.get('[role="status"]').should( + 'contain.text', + 'No sets are available yet.', + ) + }) + + it('shows malformed catalog responses as errors', () => { + cy.intercept('GET', '**/api/sets', { + statusCode: 200, + body: { sets: [{ id: 41, name: 12 }] }, + }).as('sets') + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@sets') + + cy.get('[role="alert"]').should( + 'contain.text', + "We couldn't load the available sets.", + ) + }) + + it('retries after a catalog request fails', () => { + let requestCount = 0 + cy.intercept('GET', '**/api/sets', (request) => { + requestCount += 1 + request.alias = `sets${requestCount}` + + if (requestCount === 1) { + request.reply({ statusCode: 500 }) + return + } + + request.reply({ + statusCode: 200, + body: { sets: [{ id: 41, name: 'Bible' }] }, + }) + }) + + cy.visit('/dashboard') + cy.wait('@me') + cy.wait('@sets1') + + cy.get('[role="alert"]').should( + 'contain.text', + "We couldn't load the available sets.", + ) + cy.contains('button', 'Try again').click() + cy.wait('@sets2') + + cy.get('ul[aria-label="Available sets"] h2').should( + 'have.text', + 'Bible', + ) + }) +}) diff --git a/frontend/website/src/stores/sets.ts b/frontend/website/src/stores/sets.ts new file mode 100644 index 0000000..57b0bcc --- /dev/null +++ b/frontend/website/src/stores/sets.ts @@ -0,0 +1,65 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { z } from 'zod' + +import { API_BASE } from '@/utils/apiBase' + +export const setSummarySchema = z.object({ + id: z.number().int().positive(), + name: z.string().min(1), +}) + +const setsResponseSchema = z.object({ + sets: z.array(setSummarySchema), +}) + +export type SetSummary = z.infer + +const LOAD_ERROR = "We couldn't load the available sets." + +export const useSetsStore = defineStore('sets', () => { + const sets = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchSets(): Promise { + loading.value = true + error.value = null + + try { + const response = await fetch(`${API_BASE}/api/sets`, { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }) + + if (response.status !== 200) { + sets.value = [] + error.value = LOAD_ERROR + + return false + } + + const responseBody: unknown = await response.json() + sets.value = setsResponseSchema.parse(responseBody).sets + + return true + } catch { + sets.value = [] + error.value = LOAD_ERROR + + return false + } finally { + loading.value = false + } + } + + return { + sets, + loading, + error, + fetchSets, + } +}) diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index c61783f..182def5 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -1,12 +1,21 @@