diff --git a/ai/backend-context.md b/ai/backend-context.md index 83c2081..9214acd 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -50,9 +50,6 @@ 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 deleted file mode 100644 index e432739..0000000 --- a/backend/app/Http/Controllers/SetController.php +++ /dev/null @@ -1,26 +0,0 @@ - $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 c9490ba..2255353 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -16,8 +16,6 @@ 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; @@ -47,10 +45,6 @@ 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 deleted file mode 100644 index ac7b460..0000000 --- a/backend/app/Set/CreateSetDto.php +++ /dev/null @@ -1,13 +0,0 @@ - $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 deleted file mode 100644 index 064e475..0000000 --- a/backend/app/Set/Set.php +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 021b3a5..0000000 --- a/backend/app/Set/SetModel.php +++ /dev/null @@ -1,26 +0,0 @@ -|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 deleted file mode 100644 index 0545ab1..0000000 --- a/backend/app/Set/SetRepository.php +++ /dev/null @@ -1,13 +0,0 @@ - - */ - public function all(): array; -} diff --git a/backend/app/Set/UseCases/ListSets/ListSets.php b/backend/app/Set/UseCases/ListSets/ListSets.php deleted file mode 100644 index d4e80df..0000000 --- a/backend/app/Set/UseCases/ListSets/ListSets.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ - 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 deleted file mode 100644 index 589609b..0000000 --- a/backend/database/migrations/2026_08_03_000000_create_sets_table.php +++ /dev/null @@ -1,24 +0,0 @@ -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 31a5820..def224c 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -12,6 +12,5 @@ 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 deleted file mode 100644 index d1ecf95..0000000 --- a/backend/database/seeders/SetSeeder.php +++ /dev/null @@ -1,45 +0,0 @@ -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 62384a9..d471fcf 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,7 +1,6 @@ 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 deleted file mode 100644 index a4cff4c..0000000 --- a/backend/tests/Fakes/FakeSetRepository.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ - 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 45a7e51..ca1ec91 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -3,10 +3,8 @@ 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; @@ -38,26 +36,4 @@ 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 deleted file mode 100644 index 820f0b9..0000000 --- a/backend/tests/Feature/Set/EloquentSetRepositoryTest.php +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 6f8085b..0000000 --- a/backend/tests/Feature/Set/ListSetsEndpointTest.php +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index ed7d75d..0000000 --- a/backend/tests/Unit/Set/SetTest.php +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index fbdf5fb..0000000 --- a/backend/tests/Unit/Set/UseCases/ListSetsTest.php +++ /dev/null @@ -1,40 +0,0 @@ -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 deabca6..075909d 100644 --- a/frontend/website/cypress/e2e/confirm-email.cy.ts +++ b/frontend/website/cypress/e2e/confirm-email.cy.ts @@ -9,10 +9,6 @@ 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', () => { @@ -35,7 +31,7 @@ describe('email confirmation', () => { cy.wait('@confirmEmail') cy.location('pathname').should('equal', '/dashboard') - cy.get('h1').should('have.text', 'Available sets') + cy.get('h1').should('have.text', 'Your next step starts here.') }) 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 defb942..cd35dfd 100644 --- a/frontend/website/cypress/e2e/session-auth.cy.ts +++ b/frontend/website/cypress/e2e/session-auth.cy.ts @@ -38,13 +38,6 @@ 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, @@ -55,7 +48,7 @@ describe('session authentication', () => { cy.wait('@me') cy.location('pathname').should('equal', '/dashboard') - cy.get('h1').should('have.text', 'Available sets') + cy.get('h1').should('have.text', 'Your next step starts here.') }) 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 deleted file mode 100644 index 3872b10..0000000 --- a/frontend/website/cypress/e2e/sets-dashboard.cy.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 57b0bcc..0000000 --- a/frontend/website/src/stores/sets.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 182def5..c61783f 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -1,21 +1,12 @@