diff --git a/DailyGoals.drawio b/DailyGoals.drawio index b0937cb..ca3101c 100644 --- a/DailyGoals.drawio +++ b/DailyGoals.drawio @@ -37,12 +37,6 @@ - - - - - - diff --git a/ai/backend_prompt_template.md b/ai/backend_prompt_template.md index c16ef26..1aa846d 100644 --- a/ai/backend_prompt_template.md +++ b/ai/backend_prompt_template.md @@ -20,41 +20,29 @@ Code patterns to follow: - Entities: constructor with properties, getters - DTOs: simple data containers for creation - Repositories: interfaces that define data access - - Do not write unit tests for concrete repository implementations - (e.g., Doctrine/persistence-backed). They are exercised by e2e - tests. Use cases are tested with fake repositories. - Use cases: business logic with Request objects - When throwing exceptions, add @throws docblock - Fakes: in-memory implementations for testing - Look at tests/Fakes/ for examples - Find/lookup methods must return a new instance of the entity, not the stored reference - Tests: follow existing patterns in tests/Unit/[Entity]/UseCases/ - - In setUp, only use fake repositories for entities under test - construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories -- Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily + - In setUp, only use fake repositories for entities under test — construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories +- Lines should not exceed 80 columns, but should use up to 80 columns when possible — do not split lines unnecessarily - Imports: always put use statements at the top of the file, never use inline imports (e.g., \App\Foo\Bar::class) -- Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e) -- Never use em-dashes (—) in code, comments, commit messages, or any - written output. Use a regular hyphen (-), a colon, or rephrase - with parentheses instead. +- Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e) Git commit style: -- Subject: present tense, imperative mood (add, create, test, fix) -- Subject: lowercase, short (3-6 words) -- Match subject patterns found in git history -- Add a body when the change needs explanation beyond the subject - - e.g., why the change was made, non-obvious tradeoffs, or notable - implementation details. Skip the body for trivial/self-evident commits. -- Separate subject and body with a blank line; wrap body at ~72 columns +- Present tense, imperative mood (add, create, test, fix) +- Lowercase +- Short (3-6 words) +- Match patterns found in git history Git commits: - Tests should be committed first, before implementation -- Group related changes together in a single commit (e.g., a new class - plus its registration, or a getter plus the property it exposes). - Avoid mixing unrelated concerns in one commit. -- Keep commits small and focused - prefer many small commits over few - large ones, but don't artificially split a single logical change - across multiple commits +- One commit per file - each new file gets its own commit +- Make commits SMALL and FREQUENT - every meaningful change should be a commit - Commits are for reviewing and documenting the development of code +- A commit can be as simple as adding one import, one getter, one property, etc. - Don't wait to commit - commit as you go - Run `php-cs-fixer fix` on worked on directories before committing diff --git a/ai/frontend_prompt_template.md b/ai/frontend_prompt_template.md index a865577..24eb7c3 100644 --- a/ai/frontend_prompt_template.md +++ b/ai/frontend_prompt_template.md @@ -17,31 +17,22 @@ Code patterns to follow: - First, explore the codebase to understand existing entity patterns - Look at similar pages for reference - Tests: follow existing patterns in cypress/e2e/ -- Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily +- Lines should not exceed 80 columns, but should use up to 80 columns when possible — do not split lines unnecessarily - Imports: always put imports at the top of the file -- Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e) -- Never use em-dashes (—) in code, comments, commit messages, or any - written output. Use a regular hyphen (-), a colon, or rephrase - with parentheses instead. +- Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e) Git commit style: -- Subject: present tense, imperative mood (add, create, test, fix) -- Subject: lowercase, short (3-6 words) -- Match subject patterns found in git history -- Add a body when the change needs explanation beyond the subject - - e.g., why the change was made, non-obvious tradeoffs, or notable - implementation details. Skip the body for trivial/self-evident commits. -- Separate subject and body with a blank line; wrap body at ~72 columns +- Present tense, imperative mood (add, create, test, fix) +- Lowercase +- Short (3-6 words) +- Match patterns found in git history Git commits: - Tests should be committed first, before implementation -- Group related changes together in a single commit (e.g., a new class - plus its registration, or a getter plus the property it exposes). - Avoid mixing unrelated concerns in one commit. -- Keep commits small and focused - prefer many small commits over few - large ones, but don't artificially split a single logical change - across multiple commits +- One commit per file - each new file gets its own commit +- Make commits SMALL and FREQUENT - every meaningful change should be a commit - Commits are for reviewing and documenting the development of code +- A commit can be as simple as adding one import, one getter, one property, etc. - Don't wait to commit - commit as you go Branch naming: diff --git a/app/Auth/AdminMiddleware.php b/app/Auth/AdminMiddleware.php deleted file mode 100644 index 47e16be..0000000 --- a/app/Auth/AdminMiddleware.php +++ /dev/null @@ -1,64 +0,0 @@ -getAttribute('user'); - - if (!$user instanceof User || !$user->isAdmin()) { - return $this->forbidden($request); - } - - return $handler->handle($request); - } - - private function forbidden( - ServerRequestInterface $request - ): ResponseInterface { - $response = new Response(403); - - if ($this->wantsJson($request)) { - $response->getBody()->write( - json_encode(['error' => 'forbidden']) - ); - return $response->withHeader( - 'Content-Type', - 'application/json' - ); - } - - $html = file_get_contents( - __DIR__ . '/../../views/templates/forbidden.php' - ); - $response->getBody()->write($html); - - return $response->withHeader('Content-Type', 'text/html'); - } - - private function wantsJson(ServerRequestInterface $request): bool - { - $path = $request->getUri()->getPath(); - if (str_starts_with($path, '/api/')) { - return true; - } - - $accept = $request->getHeaderLine('Accept'); - if (str_contains($accept, 'application/json')) { - return true; - } - - return false; - } -} diff --git a/app/Auth/AuthController.php b/app/Auth/AuthController.php deleted file mode 100644 index 0864730..0000000 --- a/app/Auth/AuthController.php +++ /dev/null @@ -1,166 +0,0 @@ -parseBody($request); - - try { - $user = $authenticateUser->execute( - new AuthenticateUserRequest( - email: $data['email'] ?? null, - password: $data['password'] ?? null, - ) - ); - } catch (BadRequestException $exception) { - return $this->errorResponse( - $response, - 400, - $exception->getMessage() - ); - } catch (UnauthorizedException $exception) { - return $this->errorResponse( - $response, - 401, - $exception->getMessage() - ); - } - - $session = $createSession->execute($user); - - return $this->userResponse($response, $user) - ->withHeader( - 'Set-Cookie', - $this->buildSetCookie($session->getToken()) - ); - } - - public function register( - Request $request, - Response $response, - CreateUser $createUser, - CreateSession $createSession, - ): Response { - $data = $this->parseBody($request); - - try { - $user = $createUser->execute(new CreateUserRequest( - email: $data['email'] ?? null, - password: $data['password'] ?? null, - isAdmin: false, - )); - } catch (BadRequestException $exception) { - return $this->errorResponse( - $response, - 400, - $exception->getMessage() - ); - } - - $session = $createSession->execute($user); - - return $this->userResponse($response, $user) - ->withHeader( - 'Set-Cookie', - $this->buildSetCookie($session->getToken()) - ); - } - - public function logout( - Request $request, - Response $response, - SessionRepository $sessionRepo, - ): Response { - $cookies = $request->getCookieParams(); - $token = $cookies[AuthMiddleware::COOKIE_NAME] ?? null; - - if ($token !== null) { - $sessionRepo->deleteByToken($token); - } - - return $response->withStatus(204) - ->withHeader('Set-Cookie', $this->buildClearCookie()); - } - - public function me(Request $request, Response $response): Response - { - $user = $request->getAttribute('user'); - if (!$user instanceof User) { - return $this->errorResponse( - $response, - 401, - 'unauthenticated' - ); - } - - return $this->userResponse($response, $user); - } - - private function parseBody(Request $request): array - { - return json_decode((string) $request->getBody(), true) ?? []; - } - - private function userResponse(Response $response, User $user): Response - { - $response->getBody()->write(json_encode([ - 'user' => [ - 'id' => $user->getId(), - 'email' => $user->getEmail()->value(), - 'isAdmin' => $user->isAdmin(), - ], - ])); - - return $response->withHeader( - 'Content-Type', - 'application/json' - ); - } - - private function errorResponse( - Response $response, - int $status, - string $message, - ): Response { - $response->getBody()->write( - json_encode(['error' => $message]) - ); - - return $response->withStatus($status) - ->withHeader('Content-Type', 'application/json'); - } - - private function buildSetCookie(string $token): string - { - $maxAge = self::COOKIE_MAX_AGE; - - return AuthMiddleware::COOKIE_NAME . '=' . $token - . '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' . $maxAge; - } - - private function buildClearCookie(): string - { - return AuthMiddleware::COOKIE_NAME . '=;' - . ' Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; - } -} diff --git a/app/Auth/AuthMiddleware.php b/app/Auth/AuthMiddleware.php deleted file mode 100644 index e72fba8..0000000 --- a/app/Auth/AuthMiddleware.php +++ /dev/null @@ -1,84 +0,0 @@ -getCookieParams(); - $token = $cookies[self::COOKIE_NAME] ?? null; - - if ($token === null) { - return $this->unauthorized($request); - } - - $session = $this->sessionRepo->findByToken($token); - if ($session === null) { - return $this->unauthorized($request); - } - - if ($session->isExpired($this->clock->now())) { - $this->sessionRepo->deleteByToken($token); - return $this->unauthorized($request); - } - - $user = $this->userRepo->find($session->getUserId()); - if ($user === null) { - return $this->unauthorized($request); - } - - return $handler->handle( - $request->withAttribute('user', $user) - ); - } - - private function unauthorized( - ServerRequestInterface $request - ): ResponseInterface { - if ($this->wantsJson($request)) { - $response = new Response(401); - $response->getBody()->write( - json_encode(['error' => 'unauthenticated']) - ); - return $response->withHeader( - 'Content-Type', - 'application/json' - ); - } - - return new Response(302)->withHeader('Location', '/login'); - } - - private function wantsJson(ServerRequestInterface $request): bool - { - $path = $request->getUri()->getPath(); - if (str_starts_with($path, '/api/')) { - return true; - } - - $accept = $request->getHeaderLine('Accept'); - if (str_contains($accept, 'application/json')) { - return true; - } - - return false; - } -} diff --git a/app/Auth/BcryptPasswordHasher.php b/app/Auth/BcryptPasswordHasher.php deleted file mode 100644 index 8593710..0000000 --- a/app/Auth/BcryptPasswordHasher.php +++ /dev/null @@ -1,16 +0,0 @@ -filePath = __DIR__ . '/../../data/sessions.json'; - } - - public function create(CreateSessionDto $dto): Session - { - $sessions = $this->readSessions(); - - $sessions[] = [ - 'token' => $dto->token, - 'userId' => $dto->userId, - 'createdAt' => $dto->createdAt->format(DATE_ATOM), - 'expiresAt' => $dto->expiresAt->format(DATE_ATOM), - ]; - $this->writeSessions($sessions); - - return new Session( - token: $dto->token, - userId: $dto->userId, - createdAt: $dto->createdAt, - expiresAt: $dto->expiresAt, - ); - } - - public function findByToken(string $token): ?Session - { - $sessions = $this->readSessions(); - - foreach ($sessions as $data) { - if ($data['token'] === $token) { - return new Session( - token: $data['token'], - userId: $data['userId'], - createdAt: new DateTimeImmutable($data['createdAt']), - expiresAt: new DateTimeImmutable($data['expiresAt']), - ); - } - } - - return null; - } - - public function deleteByToken(string $token): void - { - $sessions = $this->readSessions(); - $filtered = array_values(array_filter( - $sessions, - function (array $data) use ($token) { - return $data['token'] !== $token; - } - )); - $this->writeSessions($filtered); - } - - private function readSessions(): array - { - if (!file_exists($this->filePath)) { - return []; - } - - $content = file_get_contents($this->filePath); - - return json_decode($content, true) ?? []; - } - - private function writeSessions(array $sessions): void - { - file_put_contents( - $this->filePath, - json_encode($sessions, JSON_PRETTY_PRINT) - ); - } -} diff --git a/app/Auth/PasswordHasher.php b/app/Auth/PasswordHasher.php deleted file mode 100644 index 332d105..0000000 --- a/app/Auth/PasswordHasher.php +++ /dev/null @@ -1,10 +0,0 @@ -token; - } - - public function getUserId(): int - { - return $this->userId; - } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function getExpiresAt(): DateTimeImmutable - { - return $this->expiresAt; - } - - public function isExpired(DateTimeImmutable $now): bool - { - return $now >= $this->expiresAt; - } -} diff --git a/app/Auth/SessionRepository.php b/app/Auth/SessionRepository.php deleted file mode 100644 index 073a16c..0000000 --- a/app/Auth/SessionRepository.php +++ /dev/null @@ -1,10 +0,0 @@ -clock->now(); - $expiresAt = $now->modify(self::SESSION_LIFETIME); - - return $this->sessionRepo->create(new CreateSessionDto( - token: $this->tokenGenerator->generate(), - userId: $user->getId(), - createdAt: $now, - expiresAt: $expiresAt, - )); - } -} diff --git a/app/Exceptions/ForbiddenException.php b/app/Exceptions/ForbiddenException.php deleted file mode 100644 index c9b05cd..0000000 --- a/app/Exceptions/ForbiddenException.php +++ /dev/null @@ -1,5 +0,0 @@ -getAttribute('user'); - if (!$user instanceof User) { - $response->getBody()->write( - json_encode(['error' => 'unauthenticated']) - ); - return $response->withStatus(401) - ->withHeader('Content-Type', 'application/json'); - } - $data = json_decode((string) $request->getBody(), true) ?? []; + $userId = isset($data['userId']) ? (int) $data['userId'] : null; $textId = isset($data['textId']) ? (int) $data['textId'] : null; $name = $data['name'] ?? null; $dateStart = $data['dateStart'] ?? null; @@ -35,7 +26,7 @@ class PlanController try { $plan = $createPlanUseCase->execute(new CreatePlanRequest( - userId: $user->getId(), + userId: $userId, textId: $textId, name: $name, dateStart: $dateStart, diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php index d5de3a2..bde8c62 100644 --- a/app/User/JsonUserRepository.php +++ b/app/User/JsonUserRepository.php @@ -21,17 +21,13 @@ class JsonUserRepository implements UserRepository $users[] = [ 'id' => $id, - 'email' => $dto->email->value(), - 'passwordHash' => $dto->passwordHash, - 'isAdmin' => $dto->isAdmin, + 'email' => (string) $dto->email, ]; $this->writeUsers($users); return new User( id: $id, email: $dto->email, - passwordHash: $dto->passwordHash, - isAdmin: $dto->isAdmin, ); } @@ -41,36 +37,16 @@ class JsonUserRepository implements UserRepository foreach ($users as $data) { if ($data['id'] === $id) { - return $this->hydrate($data); + return new User( + id: $data['id'], + email: new EmailAddress($data['email']), + ); } } return null; } - public function findByEmail(EmailAddress $email): ?User - { - $users = $this->readUsers(); - - foreach ($users as $data) { - if ($data['email'] === $email->value()) { - return $this->hydrate($data); - } - } - - return null; - } - - private function hydrate(array $data): User - { - return new User( - id: $data['id'], - email: new EmailAddress($data['email']), - passwordHash: $data['passwordHash'] ?? '', - isAdmin: $data['isAdmin'] ?? false, - ); - } - private function readUsers(): array { if (!file_exists($this->filePath)) { diff --git a/app/User/UseCases/AuthenticateUser.php b/app/User/UseCases/AuthenticateUser.php deleted file mode 100644 index a889a06..0000000 --- a/app/User/UseCases/AuthenticateUser.php +++ /dev/null @@ -1,50 +0,0 @@ -email === null) { - throw new BadRequestException('email is required'); - } - - if ($request->password === null) { - throw new BadRequestException('password is required'); - } - - $user = $this->userRepo->findByEmail( - new EmailAddress($request->email) - ); - if ($user === null) { - throw new UnauthorizedException('invalid credentials'); - } - - $passwordMatches = $this->passwordHasher->verify( - $request->password, - $user->getPasswordHash() - ); - if (!$passwordMatches) { - throw new UnauthorizedException('invalid credentials'); - } - - return $user; - } -} diff --git a/app/User/UseCases/AuthenticateUserRequest.php b/app/User/UseCases/AuthenticateUserRequest.php deleted file mode 100644 index 953ff6e..0000000 --- a/app/User/UseCases/AuthenticateUserRequest.php +++ /dev/null @@ -1,11 +0,0 @@ -email === null) { throw new BadRequestException('email is required'); } - if ($dto->password === null) { - throw new BadRequestException('password is required'); - } - - if (strlen($dto->password) < 8) { - throw new BadRequestException( - 'password must be at least 8 characters' - ); - } - - $email = new EmailAddress($dto->email); - if ($this->userRepo->findByEmail($email) !== null) { - throw new BadRequestException('email already taken'); - } - - return $this->userRepo->create(new CreateUserDto( - email: $email, - passwordHash: $this->passwordHasher->hash($dto->password), - isAdmin: $dto->isAdmin, + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress($dto->email), )); } } diff --git a/app/User/UseCases/CreateUserDto.php b/app/User/UseCases/CreateUserDto.php index a9c38fa..e978287 100644 --- a/app/User/UseCases/CreateUserDto.php +++ b/app/User/UseCases/CreateUserDto.php @@ -8,7 +8,5 @@ class CreateUserDto { public function __construct( public EmailAddress $email, - public string $passwordHash, - public bool $isAdmin = false, ) {} } diff --git a/app/User/UseCases/CreateUserRequest.php b/app/User/UseCases/CreateUserRequest.php index c70ef72..7c48913 100644 --- a/app/User/UseCases/CreateUserRequest.php +++ b/app/User/UseCases/CreateUserRequest.php @@ -6,7 +6,5 @@ class CreateUserRequest { public function __construct( public ?string $email, - public ?string $password, - public bool $isAdmin, ) {} } diff --git a/app/User/User.php b/app/User/User.php index 9e8ae97..e685a32 100644 --- a/app/User/User.php +++ b/app/User/User.php @@ -9,8 +9,6 @@ class User public function __construct( private int $id, private EmailAddress $email, - private string $passwordHash, - private bool $isAdmin, ) {} public function getId(): int @@ -22,14 +20,4 @@ class User { return $this->email; } - - public function getPasswordHash(): string - { - return $this->passwordHash; - } - - public function isAdmin(): bool - { - return $this->isAdmin; - } } diff --git a/app/User/UserRepository.php b/app/User/UserRepository.php index 278cd98..c58a649 100644 --- a/app/User/UserRepository.php +++ b/app/User/UserRepository.php @@ -3,11 +3,9 @@ namespace App\User; use App\User\UseCases\CreateUserDto; -use App\ValueObjects\EmailAddress; interface UserRepository { public function create(CreateUserDto $dto): User; public function find(int $id): ?User; - public function findByEmail(EmailAddress $email): ?User; } diff --git a/app/ValueObjects/EmailAddress.php b/app/ValueObjects/EmailAddress.php index e646f4c..6952ae9 100644 --- a/app/ValueObjects/EmailAddress.php +++ b/app/ValueObjects/EmailAddress.php @@ -11,11 +11,6 @@ class EmailAddress $this->normalized = $email; } - public function value(): string - { - return $this->normalized; - } - public function __toString(): string { return $this->normalized; diff --git a/app/View/ViewController.php b/app/View/ViewController.php index 4fd8d84..c131683 100644 --- a/app/View/ViewController.php +++ b/app/View/ViewController.php @@ -37,24 +37,4 @@ class ViewController return $response; } - - public function login(Response $response): Response - { - $html = file_get_contents( - __DIR__ . '/../../views/templates/login.php' - ); - $response->getBody()->write($html); - - return $response; - } - - public function register(Response $response): Response - { - $html = file_get_contents( - __DIR__ . '/../../views/templates/register.php' - ); - $response->getBody()->write($html); - - return $response; - } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c05dc0f..1ceeb8e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,10 +3,6 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use DI\Bridge\Slim\Bridge; -use Slim\Routing\RouteCollectorProxy; -use App\Auth\AdminMiddleware; -use App\Auth\AuthController; -use App\Auth\AuthMiddleware; use App\View\ViewController; use App\Text\TextController; use App\Node\NodeController; @@ -18,48 +14,19 @@ $app = Bridge::create($container); // change first param to false for production $app->addErrorMiddleware(true, true, true); -// Public routes (no auth required) -$app->get('/login', [ViewController::class, 'login']); -$app->get('/register', [ViewController::class, 'register']); -$app->post('/api/auth/login', [AuthController::class, 'login']); -$app->post('/api/auth/register', [AuthController::class, 'register']); +$app->get('/home', [ViewController::class, 'home']); +$app->get('/admin', [ViewController::class, 'admin']); +$app->get('/admin/texts', [ViewController::class, 'texts']); +$app->get('/admin/texts/{textId}', [ViewController::class, 'text']); -// Authenticated routes (any logged-in user) -$app->group('', function (RouteCollectorProxy $group) { - $group->get('/home', [ViewController::class, 'home']); +$app->get('/api/texts', [TextController::class, 'getTexts']); +$app->get('/api/texts/{textId}', [TextController::class, 'getText']); +$app->post('/api/texts', [TextController::class, 'createText']); - $group->post('/api/auth/logout', [AuthController::class, 'logout']); - $group->get('/api/auth/me', [AuthController::class, 'me']); +$app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']); +$app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']); +$app->post('/api/nodes', [NodeController::class, 'createNode']); - $group->get('/api/texts', [TextController::class, 'getTexts']); - $group->get( - '/api/texts/{textId}', - [TextController::class, 'getText'] - ); - - $group->get( - '/api/nodes/{textId}', - [NodeController::class, 'getNodesOfText'] - ); - - $group->post('/api/plans', [PlanController::class, 'createPlan']); -})->add(AuthMiddleware::class); - -// Admin-only routes -$app->group('', function (RouteCollectorProxy $group) { - $group->get('/admin', [ViewController::class, 'admin']); - $group->get('/admin/texts', [ViewController::class, 'texts']); - $group->get( - '/admin/texts/{textId}', - [ViewController::class, 'text'] - ); - - $group->post('/api/texts', [TextController::class, 'createText']); - $group->post( - '/api/nodes/bulk', - [NodeController::class, 'bulkCreateNodes'] - ); - $group->post('/api/nodes', [NodeController::class, 'createNode']); -})->add(AdminMiddleware::class)->add(AuthMiddleware::class); +$app->post('/api/plans', [PlanController::class, 'createPlan']); return $app; diff --git a/bootstrap/container.php b/bootstrap/container.php index 2ae6e4c..fb80996 100644 --- a/bootstrap/container.php +++ b/bootstrap/container.php @@ -2,14 +2,6 @@ use DI; use DI\Container; -use App\Auth\BcryptPasswordHasher; -use App\Auth\Clock; -use App\Auth\JsonSessionRepository; -use App\Auth\PasswordHasher; -use App\Auth\RandomTokenGenerator; -use App\Auth\SessionRepository; -use App\Auth\SystemClock; -use App\Auth\TokenGenerator; use App\Text\TextRepository; use App\Text\JsonTextRepository; use App\Node\NodeRepository; @@ -28,11 +20,6 @@ $container = new Container([ UserRepository::class => DI\autowire(JsonUserRepository::class), ScheduledNodeRepository::class => DI\autowire(JsonScheduledNodeRepository::class), - SessionRepository::class => - DI\autowire(JsonSessionRepository::class), - TokenGenerator::class => DI\autowire(RandomTokenGenerator::class), - Clock::class => DI\autowire(SystemClock::class), - PasswordHasher::class => DI\autowire(BcryptPasswordHasher::class), ]); return $container; diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js index 3bd205f..2c8054c 100644 --- a/cypress/e2e/admin.cy.js +++ b/cypress/e2e/admin.cy.js @@ -1,7 +1,6 @@ describe('The admin page', () => { beforeEach(() => { cy.exec('npm run db:seed') - cy.loginAsAdmin() cy.visit('/admin') }) afterEach(() => { diff --git a/cypress/e2e/adminText.cy.js b/cypress/e2e/adminText.cy.js index f5cad89..84be4cd 100644 --- a/cypress/e2e/adminText.cy.js +++ b/cypress/e2e/adminText.cy.js @@ -1,7 +1,6 @@ describe('The admin text detail page', () => { beforeEach(() => { cy.exec('npm run db:seed') - cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') diff --git a/cypress/e2e/adminTextBulkAdd.cy.js b/cypress/e2e/adminTextBulkAdd.cy.js index 7d1afc2..03159a2 100644 --- a/cypress/e2e/adminTextBulkAdd.cy.js +++ b/cypress/e2e/adminTextBulkAdd.cy.js @@ -1,7 +1,6 @@ describe('Bulk add children on the admin text detail page', () => { beforeEach(() => { cy.exec('npm run db:seed') - cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') diff --git a/cypress/e2e/adminTextToggle.cy.js b/cypress/e2e/adminTextToggle.cy.js index 23cf830..c75132b 100644 --- a/cypress/e2e/adminTextToggle.cy.js +++ b/cypress/e2e/adminTextToggle.cy.js @@ -1,7 +1,6 @@ describe('Toggle display of child nodes', () => { beforeEach(() => { cy.exec('npm run db:seed') - cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js deleted file mode 100644 index ae22d22..0000000 --- a/cypress/e2e/auth.cy.js +++ /dev/null @@ -1,87 +0,0 @@ -describe('Authentication flows', () => { - beforeEach(() => { - cy.exec('npm run db:seed') - }) - - afterEach(() => { - cy.exec('npm run db:wipe') - }) - - it('unauthenticated home redirects to login', () => { - cy.visit('/home') - cy.url().should('include', '/login') - }) - - it('login form submits and redirects to home', () => { - cy.visit('/login') - cy.get('#email').type('user@example.com') - cy.get('#password').type('password1') - cy.get('#login-form').submit() - cy.url().should('include', '/home') - cy.get('h1').should('contain', 'Home') - }) - - it('login shows error on wrong password', () => { - cy.visit('/login') - cy.get('#email').type('user@example.com') - cy.get('#password').type('wrongpassword') - cy.get('#login-form').submit() - cy.get('#login-error').should('be.visible') - cy.url().should('include', '/login') - }) - - it('register creates user and redirects to home', () => { - cy.visit('/register') - cy.get('#email').type('fresh@example.com') - cy.get('#password').type('password1') - cy.get('#register-form').submit() - cy.url().should('include', '/home') - }) - - it('register shows error on short password', () => { - cy.visit('/register') - cy.get('#email').type('another@example.com') - cy.get('#password').invoke( - 'removeAttr', - 'minlength' - ) - cy.get('#password').type('short') - cy.get('#register-form').submit() - cy.get('#register-error').should('be.visible') - cy.url().should('include', '/register') - }) - - it('register shows error on duplicate email', () => { - cy.visit('/register') - cy.get('#email').type('user@example.com') - cy.get('#password').type('password1') - cy.get('#register-form').submit() - cy.get('#register-error').should('be.visible') - cy.url().should('include', '/register') - }) - - it('logout clears session and redirects to login', () => { - cy.loginAsUser() - cy.visit('/home') - cy.get('#logout').click() - cy.url().should('include', '/login') - cy.visit('/home') - cy.url().should('include', '/login') - }) - - it('non-admin user hitting /admin gets 403', () => { - cy.loginAsUser() - cy.request({ - url: '/admin', - failOnStatusCode: false, - }).then((response) => { - expect(response.status).to.eq(403) - }) - }) - - it('admin user can access /admin', () => { - cy.loginAsAdmin() - cy.visit('/admin') - cy.get('#texts').should('exist') - }) -}) diff --git a/cypress/e2e/home.cy.js b/cypress/e2e/home.cy.js index 83c6984..d0ba1fe 100644 --- a/cypress/e2e/home.cy.js +++ b/cypress/e2e/home.cy.js @@ -1,7 +1,6 @@ describe('The home page', () => { beforeEach(() => { cy.exec('npm run db:seed') - cy.loginAsUser() }) afterEach(() => { cy.exec('npm run db:wipe') diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js index 8509a61..3c9ee87 100644 --- a/cypress/e2e/homeCreatePlan.cy.js +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -1,7 +1,6 @@ describe('Create plan modal on the home page', () => { beforeEach(() => { cy.exec('npm run db:seed') - cy.loginAsUser() cy.intercept('GET', '/api/texts').as('getTexts') cy.visit('/home') cy.wait('@getTexts') @@ -61,6 +60,7 @@ describe('Create plan modal on the home page', () => { cy.wait('@createPlan').then((createPlanRequest) => { expect(createPlanRequest.response.statusCode).to.eq(201) expect(createPlanRequest.request.body).to.deep.equal({ + userId: 0, textId: 0, name: 'My reading plan', dateStart: '2025-01-01', diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 61e6549..66ea16e 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,15 +1,25 @@ -Cypress.Commands.add('login', (email, password) => { - cy.request({ - method: 'POST', - url: '/api/auth/login', - body: { email, password }, - }) -}) - -Cypress.Commands.add('loginAsAdmin', () => { - cy.login('admin@example.com', 'admin1234') -}) - -Cypress.Commands.add('loginAsUser', () => { - cy.login('user@example.com', 'password1') -}) +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) \ No newline at end of file diff --git a/data/seedDb.php b/data/seedDb.php index aaa329f..922ca90 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -28,27 +28,15 @@ $nodes = [ ], ]; -// Default credentials: -// admin@example.com / admin1234 (admin) -// user@example.com / password1 (regular user) $users = [ [ 'id' => 0, - 'email' => 'admin@example.com', - 'passwordHash' => password_hash('admin1234', PASSWORD_DEFAULT), - 'isAdmin' => true, - ], - [ - 'id' => 1, 'email' => 'user@example.com', - 'passwordHash' => password_hash('password1', PASSWORD_DEFAULT), - 'isAdmin' => false, ], ]; $plans = []; $scheduledNodes = []; -$sessions = []; $fileDataMap = [ 'texts.json' => $texts, @@ -56,7 +44,6 @@ $fileDataMap = [ 'users.json' => $users, 'plans.json' => $plans, 'scheduledNodes.json' => $scheduledNodes, - 'sessions.json' => $sessions, ]; foreach ($fileDataMap as $file => $data) { diff --git a/data/wipeDb.php b/data/wipeDb.php index 6428a92..658bb2f 100644 --- a/data/wipeDb.php +++ b/data/wipeDb.php @@ -6,7 +6,6 @@ $files = [ 'users.json', 'plans.json', 'scheduledNodes.json', - 'sessions.json', ]; foreach ($files as $file) { diff --git a/public/js/auth.js b/public/js/auth.js deleted file mode 100644 index 0e8fc96..0000000 --- a/public/js/auth.js +++ /dev/null @@ -1,75 +0,0 @@ -async function submitAuthForm(endpoint, email, password, errorElement) { - errorElement.hidden = true; - errorElement.textContent = ''; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', - body: JSON.stringify({ email, password }), - }); - - if (response.ok) { - window.location.href = '/home'; - return; - } - - let message = 'Something went wrong'; - try { - const body = await response.json(); - if (body.error) { - message = body.error; - } - } catch (parseError) { - // fall through to generic message - } - errorElement.textContent = message; - errorElement.hidden = false; -} - -async function logout() { - await fetch('/api/auth/logout', { - method: 'POST', - credentials: 'same-origin', - }); - window.location.href = '/login'; -} - -document.addEventListener('DOMContentLoaded', () => { - const loginForm = document.getElementById('login-form'); - if (loginForm !== null) { - const errorElement = document.getElementById('login-error'); - loginForm.addEventListener('submit', async (submitEvent) => { - submitEvent.preventDefault(); - const email = document.getElementById('email').value; - const password = document.getElementById('password').value; - await submitAuthForm( - '/api/auth/login', - email, - password, - errorElement, - ); - }); - } - - const registerForm = document.getElementById('register-form'); - if (registerForm !== null) { - const errorElement = document.getElementById('register-error'); - registerForm.addEventListener('submit', async (submitEvent) => { - submitEvent.preventDefault(); - const email = document.getElementById('email').value; - const password = document.getElementById('password').value; - await submitAuthForm( - '/api/auth/register', - email, - password, - errorElement, - ); - }); - } - - const logoutButton = document.getElementById('logout'); - if (logoutButton !== null) { - logoutButton.addEventListener('click', logout); - } -}); diff --git a/public/js/home.js b/public/js/home.js index e7abc7c..b46a051 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -3,9 +3,7 @@ document.addEventListener('DOMContentLoaded', () => { const createPlanModal = document.getElementById('create-plan-modal'); async function loadTexts() { - const response = await fetch('/api/texts', { - credentials: 'same-origin', - }); + const response = await fetch('/api/texts'); const texts = await response.json(); textsList.innerHTML = texts .map(text => @@ -69,8 +67,8 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch('/api/plans', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', body: JSON.stringify({ + userId: 0, textId: textId, name: planName, dateStart: dateStart, diff --git a/public/js/text.js b/public/js/text.js index 59fc4ee..0388bf6 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -1,7 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { const textId = window.location.pathname.split('/').pop(); - fetch('/api/texts/' + textId, { credentials: 'same-origin' }) + fetch('/api/texts/' + textId) .then(res => res.json()) .then(text => { const h1 = document.createElement('h1'); @@ -13,7 +13,7 @@ document.addEventListener('DOMContentLoaded', () => { }); function fetchAndRenderNodes(textId) { - return fetch('/api/nodes/' + textId, { credentials: 'same-origin' }) + return fetch('/api/nodes/' + textId) .then(res => res.json()) .then(nodes => { const existing = document.querySelector('#text-detail > ul'); @@ -113,7 +113,6 @@ function toggleAddForm(li, parentNodeId, textId) { fetch('/api/nodes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }), }) .then(res => { @@ -158,7 +157,6 @@ function toggleBulkAddForm(li, parentNodeId, textId) { fetch('/api/nodes/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', body: JSON.stringify({ textId: parseInt(textId), parentNodeId, titlePrefix, count }), }) .then(res => { diff --git a/public/js/texts.js b/public/js/texts.js index 029196d..1937749 100644 --- a/public/js/texts.js +++ b/public/js/texts.js @@ -3,9 +3,7 @@ document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('texts-form'); async function loadTexts() { - const res = await fetch('/api/texts', { - credentials: 'same-origin', - }); + const res = await fetch('/api/texts'); const texts = await res.json(); textsList.innerHTML = texts.map(text => '
  • { const formData = new FormData(form); const res = await fetch('/api/texts', { method: 'POST', - credentials: 'same-origin', body: formData, }); if (res.ok) { diff --git a/tests/Fakes/FakeClock.php b/tests/Fakes/FakeClock.php deleted file mode 100644 index 31e649b..0000000 --- a/tests/Fakes/FakeClock.php +++ /dev/null @@ -1,36 +0,0 @@ -assertUtc($currentTime); - } - - public function now(): DateTimeImmutable - { - return $this->currentTime; - } - - public function setTime(DateTimeImmutable $newTime): void - { - $this->assertUtc($newTime); - $this->currentTime = $newTime; - } - - private function assertUtc(DateTimeImmutable $time): void - { - if ($time->getTimezone()->getOffset($time) !== 0) { - throw new InvalidArgumentException( - 'FakeClock requires a DateTimeImmutable in UTC.' - ); - } - } -} diff --git a/tests/Fakes/FakePasswordHasher.php b/tests/Fakes/FakePasswordHasher.php deleted file mode 100644 index 4e9a17d..0000000 --- a/tests/Fakes/FakePasswordHasher.php +++ /dev/null @@ -1,20 +0,0 @@ -token, - userId: $dto->userId, - createdAt: $dto->createdAt, - expiresAt: $dto->expiresAt, - ); - $this->existingSessions[$dto->token] = $session; - - return $session; - } - - public function findByToken(string $token): ?Session - { - $session = $this->existingSessions[$token] ?? null; - if ($session === null) { - return null; - } - - return new Session( - token: $session->getToken(), - userId: $session->getUserId(), - createdAt: $session->getCreatedAt(), - expiresAt: $session->getExpiresAt(), - ); - } - - public function deleteByToken(string $token): void - { - unset($this->existingSessions[$token]); - } -} diff --git a/tests/Fakes/FakeTokenGenerator.php b/tests/Fakes/FakeTokenGenerator.php deleted file mode 100644 index fbe8913..0000000 --- a/tests/Fakes/FakeTokenGenerator.php +++ /dev/null @@ -1,25 +0,0 @@ -callCount % count($this->predefinedTokens); - $this->callCount++; - - return $this->predefinedTokens[$index]; - } -} diff --git a/tests/Fakes/FakeUserRepository.php b/tests/Fakes/FakeUserRepository.php index b2e766a..062cae8 100644 --- a/tests/Fakes/FakeUserRepository.php +++ b/tests/Fakes/FakeUserRepository.php @@ -5,7 +5,6 @@ namespace Tests\Fakes; use App\User\UseCases\CreateUserDto; use App\User\User; use App\User\UserRepository; -use App\ValueObjects\EmailAddress; class FakeUserRepository implements UserRepository { @@ -29,28 +28,6 @@ class FakeUserRepository implements UserRepository return new User( id: $user->getId(), email: $user->getEmail(), - passwordHash: $user->getPasswordHash(), - isAdmin: $user->isAdmin(), - ); - } - - public function findByEmail(EmailAddress $email): ?User - { - $user = array_find( - $this->existingUsers, - function (User $user) use ($email) { - return $user->getEmail()->value() === $email->value(); - } - ); - if ($user === null) { - return null; - } - - return new User( - id: $user->getId(), - email: $user->getEmail(), - passwordHash: $user->getPasswordHash(), - isAdmin: $user->isAdmin(), ); } @@ -60,8 +37,6 @@ class FakeUserRepository implements UserRepository $user = new User( id: $id, email: $dto->email, - passwordHash: $dto->passwordHash, - isAdmin: $dto->isAdmin, ); $this->existingUsers[$id] = $user; diff --git a/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php b/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php deleted file mode 100644 index 54eecd3..0000000 --- a/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php +++ /dev/null @@ -1,118 +0,0 @@ -middleware = new AdminMiddleware(); - } - - private function makeApiRequest(?User $user): ServerRequestInterface - { - $request = new ServerRequestFactory() - ->createServerRequest('POST', 'http://localhost/api/texts'); - if ($user !== null) { - $request = $request->withAttribute('user', $user); - } - return $request; - } - - private function makeHtmlRequest(?User $user): ServerRequestInterface - { - $request = new ServerRequestFactory() - ->createServerRequest('GET', 'http://localhost/admin') - ->withHeader('Accept', 'text/html'); - if ($user !== null) { - $request = $request->withAttribute('user', $user); - } - return $request; - } - - private function makeHandler(): RequestHandlerInterface - { - return new class implements RequestHandlerInterface { - public bool $wasCalled = false; - - public function handle( - ServerRequestInterface $request - ): \Psr\Http\Message\ResponseInterface { - $this->wasCalled = true; - return new Response(200); - } - }; - } - - private function makeUser(bool $isAdmin): User - { - return new User( - id: 1, - email: new EmailAddress('test@test.com'), - passwordHash: '', - isAdmin: $isAdmin, - ); - } - - public function test_passes_through_when_user_is_admin(): void - { - $handler = $this->makeHandler(); - - $response = $this->middleware->process( - $this->makeApiRequest($this->makeUser(isAdmin: true)), - $handler, - ); - - $this->assertTrue($handler->wasCalled); - $this->assertEquals(200, $response->getStatusCode()); - } - - public function test_returns_403_json_when_user_not_admin_for_api(): void - { - $response = $this->middleware->process( - $this->makeApiRequest($this->makeUser(isAdmin: false)), - $this->makeHandler(), - ); - - $this->assertEquals(403, $response->getStatusCode()); - $this->assertStringContainsString( - 'application/json', - $response->getHeaderLine('Content-Type') - ); - } - - public function test_returns_403_html_when_user_not_admin_for_view(): void - { - $response = $this->middleware->process( - $this->makeHtmlRequest($this->makeUser(isAdmin: false)), - $this->makeHandler(), - ); - - $this->assertEquals(403, $response->getStatusCode()); - $this->assertStringContainsString( - '403 Forbidden', - (string) $response->getBody() - ); - } - - public function test_returns_403_when_no_user_attribute(): void - { - $response = $this->middleware->process( - $this->makeApiRequest(null), - $this->makeHandler(), - ); - - $this->assertEquals(403, $response->getStatusCode()); - } -} diff --git a/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php deleted file mode 100644 index 8408384..0000000 --- a/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ /dev/null @@ -1,161 +0,0 @@ -userRepo = new FakeUserRepository(); - $this->sessionRepo = new FakeSessionRepository(); - $this->clock = new FakeClock( - new DateTimeImmutable('2025-01-01T12:00:00+00:00') - ); - $this->user = $this->userRepo->create(new CreateUserDto( - email: new EmailAddress('test@test.com'), - passwordHash: '', - )); - $this->middleware = new AuthMiddleware( - $this->sessionRepo, - $this->userRepo, - $this->clock, - ); - } - - private function makeApiRequest( - ?string $cookieToken = null - ): ServerRequestInterface { - $request = new ServerRequestFactory() - ->createServerRequest('GET', 'http://localhost/api/texts'); - if ($cookieToken !== null) { - $request = $request->withCookieParams([ - 'auth_token' => $cookieToken, - ]); - } - return $request; - } - - private function makeHtmlRequest( - ?string $cookieToken = null - ): ServerRequestInterface { - $request = new ServerRequestFactory() - ->createServerRequest('GET', 'http://localhost/home') - ->withHeader('Accept', 'text/html'); - if ($cookieToken !== null) { - $request = $request->withCookieParams([ - 'auth_token' => $cookieToken, - ]); - } - return $request; - } - - private function makeHandler(): RequestHandlerInterface - { - return new class implements RequestHandlerInterface { - public ?ServerRequestInterface $capturedRequest = null; - - public function handle( - ServerRequestInterface $request - ): \Psr\Http\Message\ResponseInterface { - $this->capturedRequest = $request; - return new Response(200); - } - }; - } - - public function test_returns_401_json_when_cookie_missing(): void - { - $response = $this->middleware->process( - $this->makeApiRequest(), - $this->makeHandler(), - ); - - $this->assertEquals(401, $response->getStatusCode()); - $this->assertStringContainsString( - 'application/json', - $response->getHeaderLine('Content-Type') - ); - } - - public function test_returns_401_when_token_not_in_repo(): void - { - $response = $this->middleware->process( - $this->makeApiRequest('unknown-token'), - $this->makeHandler(), - ); - - $this->assertEquals(401, $response->getStatusCode()); - } - - public function test_returns_401_when_token_expired(): void - { - $this->sessionRepo->create(new CreateSessionDto( - token: 'expired-token', - userId: $this->user->getId(), - createdAt: new DateTimeImmutable('2024-12-01T00:00:00+00:00'), - expiresAt: new DateTimeImmutable('2024-12-08T00:00:00+00:00'), - )); - - $response = $this->middleware->process( - $this->makeApiRequest('expired-token'), - $this->makeHandler(), - ); - - $this->assertEquals(401, $response->getStatusCode()); - } - - public function test_attaches_user_to_request_on_success(): void - { - $this->sessionRepo->create(new CreateSessionDto( - token: 'valid-token', - userId: $this->user->getId(), - createdAt: new DateTimeImmutable('2025-01-01T00:00:00+00:00'), - expiresAt: new DateTimeImmutable('2025-01-08T00:00:00+00:00'), - )); - $handler = $this->makeHandler(); - - $this->middleware->process( - $this->makeApiRequest('valid-token'), - $handler, - ); - - $attached = $handler->capturedRequest->getAttribute('user'); - $this->assertInstanceOf(User::class, $attached); - $this->assertEquals( - 'test@test.com', - $attached->getEmail()->value() - ); - } - - public function test_redirects_to_login_when_html_unauthenticated(): void - { - $response = $this->middleware->process( - $this->makeHtmlRequest(), - $this->makeHandler(), - ); - - $this->assertEquals(302, $response->getStatusCode()); - $this->assertEquals('/login', $response->getHeaderLine('Location')); - } -} diff --git a/tests/Unit/Auth/UseCases/CreateSessionTest.php b/tests/Unit/Auth/UseCases/CreateSessionTest.php deleted file mode 100644 index a79eb9c..0000000 --- a/tests/Unit/Auth/UseCases/CreateSessionTest.php +++ /dev/null @@ -1,85 +0,0 @@ -sessionRepo = new FakeSessionRepository(); - $this->tokenGenerator = new FakeTokenGenerator( - ['generated-token-abc'] - ); - $this->clock = new FakeClock( - new DateTimeImmutable('2025-01-01T12:00:00+00:00') - ); - $this->useCase = new CreateSession( - $this->sessionRepo, - $this->tokenGenerator, - $this->clock, - ); - $this->user = new User( - id: 7, - email: new EmailAddress('test@test.com'), - passwordHash: 'hashed:password1', - isAdmin: false, - ); - } - - public function test_creates_session_for_user(): void - { - $session = $this->useCase->execute($this->user); - - $this->assertEquals(7, $session->getUserId()); - } - - public function test_session_token_comes_from_generator(): void - { - $session = $this->useCase->execute($this->user); - - $this->assertEquals('generated-token-abc', $session->getToken()); - } - - public function test_session_created_at_is_now(): void - { - $session = $this->useCase->execute($this->user); - - $this->assertEquals( - new DateTimeImmutable('2025-01-01T12:00:00+00:00'), - $session->getCreatedAt() - ); - } - - public function test_session_expires_in_seven_days(): void - { - $session = $this->useCase->execute($this->user); - - $this->assertEquals( - new DateTimeImmutable('2025-01-08T12:00:00+00:00'), - $session->getExpiresAt() - ); - } - - public function test_session_is_persisted(): void - { - $this->useCase->execute($this->user); - - $found = $this->sessionRepo->findByToken('generated-token-abc'); - $this->assertNotNull($found); - } -} diff --git a/tests/Unit/Plan/UseCases/CreatePlanTest.php b/tests/Unit/Plan/UseCases/CreatePlanTest.php index f88bc89..667e72c 100644 --- a/tests/Unit/Plan/UseCases/CreatePlanTest.php +++ b/tests/Unit/Plan/UseCases/CreatePlanTest.php @@ -39,7 +39,6 @@ class CreatePlanTest extends TestCase $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); $this->userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), - passwordHash: '', )); $this->createScheduledNode = new CreateScheduledNode( scheduledNodeRepo: $this->scheduledNodeRepo, @@ -233,8 +232,8 @@ class CreatePlanTest extends TestCase )); } - public function test_scheduled_nodes_are_scheduled_on_different_days(): void - { + public function test_scheduled_nodes_are_scheduled_on_different_days(): void + { $text = $this->textRepo->find(0); $rootNode = $this->nodeRepo->create(new CreateNodeDto( text: $text, @@ -270,10 +269,10 @@ class CreatePlanTest extends TestCase new DateTimeImmutable('2025-01-02'), $childTwo->getDate() ); - } + } - public function test_more_scheduled_nodes_than_days(): void - { + public function test_more_scheduled_nodes_than_days(): void + { $text = $this->textRepo->find(0); $rootNode = $this->nodeRepo->create(new CreateNodeDto( text: $text, @@ -320,5 +319,5 @@ class CreatePlanTest extends TestCase new DateTimeImmutable('2025-01-02'), $childThree->getDate() ); - } + } } diff --git a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php index 477a07a..d2bfc2b 100644 --- a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php +++ b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php @@ -30,12 +30,7 @@ class CreateScheduledNodeTest extends TestCase $this->planRepo = new FakePlanRepository(); $this->planRepo->create(new CreatePlanDto( name: 'testplan', - user: new User( - id: 0, - email: new EmailAddress('test@test.com'), - passwordHash: 'hashed:password1', - isAdmin: false, - ), + user: new User(0, new EmailAddress('test@test.com')), )); $this->useCase = new CreateScheduledNode( $this->scheduledNodeRepo, diff --git a/tests/Unit/User/UseCases/AuthenticateUserTest.php b/tests/Unit/User/UseCases/AuthenticateUserTest.php deleted file mode 100644 index 88bb1a9..0000000 --- a/tests/Unit/User/UseCases/AuthenticateUserTest.php +++ /dev/null @@ -1,95 +0,0 @@ -userRepo = new FakeUserRepository(); - $this->passwordHasher = new FakePasswordHasher(); - $createUser = new CreateUser( - $this->userRepo, - $this->passwordHasher, - ); - $createUser->execute(new CreateUserRequest( - email: 'test@test.com', - password: 'password1', - isAdmin: false, - )); - $this->useCase = new AuthenticateUser( - $this->userRepo, - $this->passwordHasher, - ); - } - - public function test_returns_user_on_valid_credentials(): void - { - $user = $this->useCase->execute(new AuthenticateUserRequest( - email: 'test@test.com', - password: 'password1', - )); - - $this->assertInstanceOf(User::class, $user); - $this->assertEquals('test@test.com', $user->getEmail()->value()); - } - - public function test_throws_bad_request_when_email_null(): void - { - $this->expectException(BadRequestException::class); - $this->expectExceptionMessage('email is required'); - - $this->useCase->execute(new AuthenticateUserRequest( - email: null, - password: 'password1', - )); - } - - public function test_throws_bad_request_when_password_null(): void - { - $this->expectException(BadRequestException::class); - $this->expectExceptionMessage('password is required'); - - $this->useCase->execute(new AuthenticateUserRequest( - email: 'test@test.com', - password: null, - )); - } - - public function test_throws_unauthorized_on_wrong_password(): void - { - $this->expectException(UnauthorizedException::class); - $this->expectExceptionMessage('invalid credentials'); - - $this->useCase->execute(new AuthenticateUserRequest( - email: 'test@test.com', - password: 'wrongpassword', - )); - } - - public function test_throws_unauthorized_when_email_not_found(): void - { - $this->expectException(UnauthorizedException::class); - $this->expectExceptionMessage('invalid credentials'); - - $this->useCase->execute(new AuthenticateUserRequest( - email: 'missing@test.com', - password: 'password1', - )); - } -} diff --git a/tests/Unit/User/UseCases/CreateUserTest.php b/tests/Unit/User/UseCases/CreateUserTest.php index 0333a52..180046f 100644 --- a/tests/Unit/User/UseCases/CreateUserTest.php +++ b/tests/Unit/User/UseCases/CreateUserTest.php @@ -6,119 +6,33 @@ use App\Exceptions\BadRequestException; use App\User\User; use App\User\UseCases\CreateUser; use App\User\UseCases\CreateUserRequest; -use Tests\Fakes\FakePasswordHasher; use Tests\Fakes\FakeUserRepository; use PHPUnit\Framework\TestCase; class CreateUserTest extends TestCase { - private FakeUserRepository $userRepo; - private FakePasswordHasher $passwordHasher; - private CreateUser $useCase; - - public function setUp(): void - { - $this->userRepo = new FakeUserRepository(); - $this->passwordHasher = new FakePasswordHasher(); - $this->useCase = new CreateUser( - $this->userRepo, - $this->passwordHasher, - ); - } - public function test_create_user(): void { - $this->useCase->execute(new CreateUserRequest( + $userRepo = new FakeUserRepository(); + $useCase = new CreateUser($userRepo); + $useCase->execute(new CreateUserRequest( email: 'test@test.com', - password: 'password1', - isAdmin: false, )); - $user = $this->userRepo->find(0); + $user = $userRepo->find(0); $this->assertInstanceOf(User::class, $user); $this->assertEquals('test@test.com', $user->getEmail()); } public function test_throws_if_email_is_null(): void { + $userRepo = new FakeUserRepository(); + $useCase = new CreateUser($userRepo); + $this->expectException(BadRequestException::class); $this->expectExceptionMessage('email is required'); - $this->useCase->execute(new CreateUserRequest( + $useCase->execute(new CreateUserRequest( email: null, - password: 'password1', - isAdmin: false, )); } - - public function test_is_admin_can_be_set_true(): void - { - $this->useCase->execute(new CreateUserRequest( - email: 'test@test.com', - password: 'password1', - isAdmin: true, - )); - $user = $this->userRepo->find(0); - $this->assertTrue($user->isAdmin()); - } - - public function test_throws_when_email_already_taken(): void - { - $this->useCase->execute(new CreateUserRequest( - email: 'test@test.com', - password: 'password1', - isAdmin: false, - )); - - $this->expectException(BadRequestException::class); - $this->expectExceptionMessage('email already taken'); - - $this->useCase->execute(new CreateUserRequest( - email: 'test@test.com', - password: 'password1', - isAdmin: false - )); - } - - public function test_throws_if_password_is_null(): void - { - $this->expectException(BadRequestException::class); - $this->expectExceptionMessage('password is required'); - - $this->useCase->execute(new CreateUserRequest( - email: 'test@test.com', - password: null, - isAdmin: false, - )); - } - - public function test_throws_if_password_too_short(): void - { - $this->expectException(BadRequestException::class); - $this->expectExceptionMessage( - 'password must be at least 8 characters' - ); - - $this->useCase->execute(new CreateUserRequest( - email: 'test@test.com', - password: 'short', - isAdmin: false, - )); - } - - public function test_stores_hashed_password(): void - { - $this->useCase->execute(new CreateUserRequest( - email: 'test@test.com', - password: 'password1', - isAdmin: false, - )); - $user = $this->userRepo->find(0); - $this->assertNotEquals('password1', $user->getPasswordHash()); - $this->assertTrue( - $this->passwordHasher->verify( - 'password1', - $user->getPasswordHash() - ) - ); - } } diff --git a/tests/e2e/Controllers/AuthControllerTest.php b/tests/e2e/Controllers/AuthControllerTest.php deleted file mode 100644 index 50e39a5..0000000 --- a/tests/e2e/Controllers/AuthControllerTest.php +++ /dev/null @@ -1,294 +0,0 @@ -userRepo = new FakeUserRepository(); - $this->sessionRepo = new FakeSessionRepository(); - $this->tokenGenerator = new FakeTokenGenerator( - ['session-token-xyz'] - ); - $this->clock = new FakeClock( - new DateTimeImmutable('2025-01-01T12:00:00+00:00') - ); - $this->passwordHasher = new FakePasswordHasher(); - - $this->createUser = new CreateUser( - $this->userRepo, - $this->passwordHasher, - ); - $this->authenticateUser = new AuthenticateUser( - $this->userRepo, - $this->passwordHasher, - ); - $this->createSession = new CreateSession( - $this->sessionRepo, - $this->tokenGenerator, - $this->clock, - ); - - $this->createUser->execute(new CreateUserRequest( - email: 'existing@test.com', - password: 'password1', - isAdmin: false, - )); - - $this->controller = new AuthController(); - } - - private function makeJsonRequest( - string $method, - string $path, - array $data, - ): ServerRequestInterface { - $body = new StreamFactory()->createStream(json_encode($data)); - return new ServerRequestFactory() - ->createServerRequest($method, 'http://localhost' . $path) - ->withHeader('Content-Type', 'application/json') - ->withBody($body); - } - - public function test_login_returns_200_and_user(): void - { - $response = $this->controller->login( - $this->makeJsonRequest('POST', '/api/auth/login', [ - 'email' => 'existing@test.com', - 'password' => 'password1', - ]), - new Response(), - $this->authenticateUser, - $this->createSession, - ); - - $this->assertEquals(200, $response->getStatusCode()); - $body = json_decode($response->getBody(), true); - $this->assertEquals( - 'existing@test.com', - $body['user']['email'] - ); - } - - public function test_login_sets_auth_cookie(): void - { - $response = $this->controller->login( - $this->makeJsonRequest('POST', '/api/auth/login', [ - 'email' => 'existing@test.com', - 'password' => 'password1', - ]), - new Response(), - $this->authenticateUser, - $this->createSession, - ); - - $setCookie = $response->getHeaderLine('Set-Cookie'); - $this->assertStringContainsString( - 'auth_token=session-token-xyz', - $setCookie - ); - $this->assertStringContainsString('HttpOnly', $setCookie); - $this->assertStringContainsString('SameSite=Lax', $setCookie); - $this->assertStringContainsString('Path=/', $setCookie); - } - - public function test_login_creates_session(): void - { - $this->controller->login( - $this->makeJsonRequest('POST', '/api/auth/login', [ - 'email' => 'existing@test.com', - 'password' => 'password1', - ]), - new Response(), - $this->authenticateUser, - $this->createSession, - ); - - $this->assertNotNull( - $this->sessionRepo->findByToken('session-token-xyz') - ); - } - - public function test_login_returns_401_on_wrong_password(): void - { - $response = $this->controller->login( - $this->makeJsonRequest('POST', '/api/auth/login', [ - 'email' => 'existing@test.com', - 'password' => 'wrongpassword', - ]), - new Response(), - $this->authenticateUser, - $this->createSession, - ); - - $this->assertEquals(401, $response->getStatusCode()); - } - - public function test_login_returns_400_when_email_missing(): void - { - $response = $this->controller->login( - $this->makeJsonRequest('POST', '/api/auth/login', [ - 'password' => 'password1', - ]), - new Response(), - $this->authenticateUser, - $this->createSession, - ); - - $this->assertEquals(400, $response->getStatusCode()); - } - - public function test_register_creates_user_and_logs_in(): void - { - $response = $this->controller->register( - $this->makeJsonRequest('POST', '/api/auth/register', [ - 'email' => 'new@test.com', - 'password' => 'password1', - ]), - new Response(), - $this->createUser, - $this->createSession, - ); - - $this->assertEquals(200, $response->getStatusCode()); - $body = json_decode($response->getBody(), true); - $this->assertEquals('new@test.com', $body['user']['email']); - $setCookie = $response->getHeaderLine('Set-Cookie'); - $this->assertStringContainsString( - 'auth_token=session-token-xyz', - $setCookie - ); - } - - public function test_register_returns_400_on_short_password(): void - { - $response = $this->controller->register( - $this->makeJsonRequest('POST', '/api/auth/register', [ - 'email' => 'new@test.com', - 'password' => 'short', - ]), - new Response(), - $this->createUser, - $this->createSession, - ); - - $this->assertEquals(400, $response->getStatusCode()); - } - - public function test_register_returns_400_on_duplicate_email(): void - { - $response = $this->controller->register( - $this->makeJsonRequest('POST', '/api/auth/register', [ - 'email' => 'existing@test.com', - 'password' => 'password1', - ]), - new Response(), - $this->createUser, - $this->createSession, - ); - - $this->assertEquals(400, $response->getStatusCode()); - } - - public function test_register_ignores_is_admin_in_body(): void - { - $this->controller->register( - $this->makeJsonRequest('POST', '/api/auth/register', [ - 'email' => 'sneaky@test.com', - 'password' => 'password1', - 'isAdmin' => true, - ]), - new Response(), - $this->createUser, - $this->createSession, - ); - - $newUser = $this->userRepo->findByEmail( - new EmailAddress('sneaky@test.com') - ); - $this->assertFalse($newUser->isAdmin()); - } - - public function test_logout_deletes_session_and_clears_cookie(): void - { - $this->sessionRepo->create(new CreateSessionDto( - token: 'existing-session', - userId: 0, - createdAt: new DateTimeImmutable('2025-01-01T00:00:00+00:00'), - expiresAt: new DateTimeImmutable('2025-01-08T00:00:00+00:00'), - )); - - $request = $this->makeJsonRequest( - 'POST', - '/api/auth/logout', - [] - )->withCookieParams(['auth_token' => 'existing-session']); - - $response = $this->controller->logout( - $request, - new Response(), - $this->sessionRepo, - ); - - $this->assertEquals(204, $response->getStatusCode()); - $this->assertNull( - $this->sessionRepo->findByToken('existing-session') - ); - $this->assertStringContainsString( - 'auth_token=;', - $response->getHeaderLine('Set-Cookie') - ); - } - - public function test_me_returns_current_user(): void - { - $user = new User( - id: 5, - email: new EmailAddress('me@test.com'), - passwordHash: '', - isAdmin: true, - ); - $request = new ServerRequestFactory() - ->createServerRequest('GET', 'http://localhost/api/auth/me') - ->withAttribute('user', $user); - - $response = $this->controller->me($request, new Response()); - - $this->assertEquals(200, $response->getStatusCode()); - $body = json_decode($response->getBody(), true); - $this->assertEquals(5, $body['user']['id']); - $this->assertEquals('me@test.com', $body['user']['email']); - $this->assertTrue($body['user']['isAdmin']); - } -} diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index cf0eeb0..ee51012 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -8,7 +8,6 @@ use App\Plan\UseCases\CreatePlan; use App\ScheduledNode\UseCases\CreateScheduledNode; use App\Text\CreateTextDto; use App\User\UseCases\CreateUserDto; -use App\User\User; use App\ValueObjects\EmailAddress; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -30,7 +29,6 @@ class PlanControllerTest extends TestCase private FakeScheduledNodeRepository $scheduledNodeRepo; private CreatePlan $createPlan; private PlanController $controller; - private User $user; public function setUp(): void { @@ -40,9 +38,8 @@ class PlanControllerTest extends TestCase $this->nodeRepo = new FakeNodeRepository(); $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); - $this->user = $this->userRepo->create(new CreateUserDto( + $this->userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), - passwordHash: '', )); $text = $this->textRepo->create(new CreateTextDto('testname')); $this->nodeRepo->create(new CreateNodeDto( @@ -71,7 +68,6 @@ class PlanControllerTest extends TestCase return new ServerRequestFactory() ->createServerRequest('POST', 'http://localhost/api/plans') ->withHeader('Content-Type', 'application/json') - ->withAttribute('user', $this->user) ->withBody($body); } @@ -79,6 +75,7 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -94,33 +91,29 @@ class PlanControllerTest extends TestCase $this->assertEquals('My Plan', $body['name']); } - public function test_create_plan_returns_401_when_no_user(): void + public function test_create_plan_returns_400_when_user_id_missing(): void { - $requestWithoutUser = new ServerRequestFactory() - ->createServerRequest('POST', 'http://localhost/api/plans') - ->withHeader('Content-Type', 'application/json') - ->withBody( - new StreamFactory()->createStream(json_encode([ - 'textId' => 0, - 'name' => 'My Plan', - 'dateStart' => '2025-01-01', - 'dateEnd' => '2025-01-01', - ])) - ); - $response = $this->controller->createPlan( - $requestWithoutUser, + $this->makeRequest([ + 'textId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), new Response(), $this->createPlan, ); - $this->assertEquals(401, $response->getStatusCode()); + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); } public function test_create_plan_returns_400_when_text_id_missing(): void { $response = $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', 'dateEnd' => '2025-01-01', @@ -138,6 +131,7 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'textId' => 0, 'dateStart' => '2025-01-01', 'dateEnd' => '2025-01-01', @@ -155,6 +149,7 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateEnd' => '2025-01-01', @@ -172,6 +167,7 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -189,6 +185,7 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-02', @@ -203,10 +200,30 @@ class PlanControllerTest extends TestCase $this->assertArrayHasKey('error', $body); } + public function test_create_plan_returns_404_when_user_not_found(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 99, + 'textId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(404, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } + public function test_create_plan_returns_404_when_text_not_found(): void { $response = $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'textId' => 99, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -225,6 +242,7 @@ class PlanControllerTest extends TestCase { $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'textId' => 0, 'name' => 'Persistent Plan', 'dateStart' => '2025-01-01', @@ -243,6 +261,7 @@ class PlanControllerTest extends TestCase { $this->controller->createPlan( $this->makeRequest([ + 'userId' => 0, 'textId' => 0, 'name' => 'Scheduling Plan', 'dateStart' => '2025-01-01', diff --git a/views/templates/admin.php b/views/templates/admin.php index f1fc83d..7f463d4 100644 --- a/views/templates/admin.php +++ b/views/templates/admin.php @@ -4,8 +4,6 @@ Daily Goals - Admin - Texts - diff --git a/views/templates/forbidden.php b/views/templates/forbidden.php deleted file mode 100644 index 43cd644..0000000 --- a/views/templates/forbidden.php +++ /dev/null @@ -1,11 +0,0 @@ - - - - Daily Goals - Forbidden - - -

    403 Forbidden

    -

    You do not have permission to access this page.

    - Back to Home - - diff --git a/views/templates/home.php b/views/templates/home.php index 4f7a096..ddbc361 100644 --- a/views/templates/home.php +++ b/views/templates/home.php @@ -5,7 +5,6 @@

    Home

    - - diff --git a/views/templates/login.php b/views/templates/login.php deleted file mode 100644 index 93f4b56..0000000 --- a/views/templates/login.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - Daily Goals - Login - - -

    Login

    -
    - - - -
    - -

    Register

    - - - diff --git a/views/templates/register.php b/views/templates/register.php deleted file mode 100644 index 223247c..0000000 --- a/views/templates/register.php +++ /dev/null @@ -1,27 +0,0 @@ - - - - Daily Goals - Register - - -

    Register

    -
    - - - -
    - -

    Already have an account? Login

    - - -