diff --git a/DailyGoals.drawio b/DailyGoals.drawio
index ca3101c..b0937cb 100644
--- a/DailyGoals.drawio
+++ b/DailyGoals.drawio
@@ -37,6 +37,12 @@
+
+
+
+
+
+
diff --git a/ai/backend_prompt_template.md b/ai/backend_prompt_template.md
index 1aa846d..c16ef26 100644
--- a/ai/backend_prompt_template.md
+++ b/ai/backend_prompt_template.md
@@ -20,29 +20,41 @@ 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)
+- 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.
Git commit style:
-- Present tense, imperative mood (add, create, test, fix)
-- Lowercase
-- Short (3-6 words)
-- Match patterns found in git history
+- 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
Git commits:
- Tests should be committed first, before implementation
-- One commit per file - each new file gets its own commit
-- Make commits SMALL and FREQUENT - every meaningful change should be a commit
+- 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
- 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 24eb7c3..a865577 100644
--- a/ai/frontend_prompt_template.md
+++ b/ai/frontend_prompt_template.md
@@ -17,22 +17,31 @@ 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)
+- 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.
Git commit style:
-- Present tense, imperative mood (add, create, test, fix)
-- Lowercase
-- Short (3-6 words)
-- Match patterns found in git history
+- 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
Git commits:
- Tests should be committed first, before implementation
-- One commit per file - each new file gets its own commit
-- Make commits SMALL and FREQUENT - every meaningful change should be a commit
+- 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
- 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
new file mode 100644
index 0000000..47e16be
--- /dev/null
+++ b/app/Auth/AdminMiddleware.php
@@ -0,0 +1,64 @@
+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
new file mode 100644
index 0000000..0864730
--- /dev/null
+++ b/app/Auth/AuthController.php
@@ -0,0 +1,166 @@
+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
new file mode 100644
index 0000000..e72fba8
--- /dev/null
+++ b/app/Auth/AuthMiddleware.php
@@ -0,0 +1,84 @@
+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
new file mode 100644
index 0000000..8593710
--- /dev/null
+++ b/app/Auth/BcryptPasswordHasher.php
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..332d105
--- /dev/null
+++ b/app/Auth/PasswordHasher.php
@@ -0,0 +1,10 @@
+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
new file mode 100644
index 0000000..073a16c
--- /dev/null
+++ b/app/Auth/SessionRepository.php
@@ -0,0 +1,10 @@
+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
new file mode 100644
index 0000000..c9b05cd
--- /dev/null
+++ b/app/Exceptions/ForbiddenException.php
@@ -0,0 +1,5 @@
+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;
@@ -26,7 +35,7 @@ class PlanController
try {
$plan = $createPlanUseCase->execute(new CreatePlanRequest(
- userId: $userId,
+ userId: $user->getId(),
textId: $textId,
name: $name,
dateStart: $dateStart,
diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php
index bde8c62..d5de3a2 100644
--- a/app/User/JsonUserRepository.php
+++ b/app/User/JsonUserRepository.php
@@ -21,13 +21,17 @@ class JsonUserRepository implements UserRepository
$users[] = [
'id' => $id,
- 'email' => (string) $dto->email,
+ 'email' => $dto->email->value(),
+ 'passwordHash' => $dto->passwordHash,
+ 'isAdmin' => $dto->isAdmin,
];
$this->writeUsers($users);
return new User(
id: $id,
email: $dto->email,
+ passwordHash: $dto->passwordHash,
+ isAdmin: $dto->isAdmin,
);
}
@@ -37,16 +41,36 @@ class JsonUserRepository implements UserRepository
foreach ($users as $data) {
if ($data['id'] === $id) {
- return new User(
- id: $data['id'],
- email: new EmailAddress($data['email']),
- );
+ return $this->hydrate($data);
}
}
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
new file mode 100644
index 0000000..a889a06
--- /dev/null
+++ b/app/User/UseCases/AuthenticateUser.php
@@ -0,0 +1,50 @@
+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
new file mode 100644
index 0000000..953ff6e
--- /dev/null
+++ b/app/User/UseCases/AuthenticateUserRequest.php
@@ -0,0 +1,11 @@
+email === null) {
throw new BadRequestException('email is required');
}
- $this->userRepo->create(new CreateUserDto(
- email: new EmailAddress($dto->email),
+ 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,
));
}
}
diff --git a/app/User/UseCases/CreateUserDto.php b/app/User/UseCases/CreateUserDto.php
index e978287..a9c38fa 100644
--- a/app/User/UseCases/CreateUserDto.php
+++ b/app/User/UseCases/CreateUserDto.php
@@ -8,5 +8,7 @@ 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 7c48913..c70ef72 100644
--- a/app/User/UseCases/CreateUserRequest.php
+++ b/app/User/UseCases/CreateUserRequest.php
@@ -6,5 +6,7 @@ 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 e685a32..9e8ae97 100644
--- a/app/User/User.php
+++ b/app/User/User.php
@@ -9,6 +9,8 @@ class User
public function __construct(
private int $id,
private EmailAddress $email,
+ private string $passwordHash,
+ private bool $isAdmin,
) {}
public function getId(): int
@@ -20,4 +22,14 @@ 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 c58a649..278cd98 100644
--- a/app/User/UserRepository.php
+++ b/app/User/UserRepository.php
@@ -3,9 +3,11 @@
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 6952ae9..e646f4c 100644
--- a/app/ValueObjects/EmailAddress.php
+++ b/app/ValueObjects/EmailAddress.php
@@ -11,6 +11,11 @@ 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 c131683..4fd8d84 100644
--- a/app/View/ViewController.php
+++ b/app/View/ViewController.php
@@ -37,4 +37,24 @@ 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 1ceeb8e..c05dc0f 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -3,6 +3,10 @@
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;
@@ -14,19 +18,48 @@ $app = Bridge::create($container);
// change first param to false for production
$app->addErrorMiddleware(true, true, true);
-$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']);
+// 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('/api/texts', [TextController::class, 'getTexts']);
-$app->get('/api/texts/{textId}', [TextController::class, 'getText']);
-$app->post('/api/texts', [TextController::class, 'createText']);
+// Authenticated routes (any logged-in user)
+$app->group('', function (RouteCollectorProxy $group) {
+ $group->get('/home', [ViewController::class, 'home']);
-$app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']);
-$app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']);
-$app->post('/api/nodes', [NodeController::class, 'createNode']);
+ $group->post('/api/auth/logout', [AuthController::class, 'logout']);
+ $group->get('/api/auth/me', [AuthController::class, 'me']);
-$app->post('/api/plans', [PlanController::class, 'createPlan']);
+ $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);
return $app;
diff --git a/bootstrap/container.php b/bootstrap/container.php
index fb80996..2ae6e4c 100644
--- a/bootstrap/container.php
+++ b/bootstrap/container.php
@@ -2,6 +2,14 @@
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;
@@ -20,6 +28,11 @@ $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 2c8054c..3bd205f 100644
--- a/cypress/e2e/admin.cy.js
+++ b/cypress/e2e/admin.cy.js
@@ -1,6 +1,7 @@
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 84be4cd..f5cad89 100644
--- a/cypress/e2e/adminText.cy.js
+++ b/cypress/e2e/adminText.cy.js
@@ -1,6 +1,7 @@
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 03159a2..7d1afc2 100644
--- a/cypress/e2e/adminTextBulkAdd.cy.js
+++ b/cypress/e2e/adminTextBulkAdd.cy.js
@@ -1,6 +1,7 @@
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 c75132b..23cf830 100644
--- a/cypress/e2e/adminTextToggle.cy.js
+++ b/cypress/e2e/adminTextToggle.cy.js
@@ -1,6 +1,7 @@
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
new file mode 100644
index 0000000..ae22d22
--- /dev/null
+++ b/cypress/e2e/auth.cy.js
@@ -0,0 +1,87 @@
+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 d0ba1fe..83c6984 100644
--- a/cypress/e2e/home.cy.js
+++ b/cypress/e2e/home.cy.js
@@ -1,6 +1,7 @@
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 3c9ee87..8509a61 100644
--- a/cypress/e2e/homeCreatePlan.cy.js
+++ b/cypress/e2e/homeCreatePlan.cy.js
@@ -1,6 +1,7 @@
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')
@@ -60,7 +61,6 @@ 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 66ea16e..61e6549 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -1,25 +1,15 @@
-// ***********************************************
-// 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
+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')
+})
diff --git a/data/seedDb.php b/data/seedDb.php
index 922ca90..aaa329f 100644
--- a/data/seedDb.php
+++ b/data/seedDb.php
@@ -28,15 +28,27 @@ $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,
@@ -44,6 +56,7 @@ $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 658bb2f..6428a92 100644
--- a/data/wipeDb.php
+++ b/data/wipeDb.php
@@ -6,6 +6,7 @@ $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
new file mode 100644
index 0000000..0e8fc96
--- /dev/null
+++ b/public/js/auth.js
@@ -0,0 +1,75 @@
+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 b46a051..e7abc7c 100644
--- a/public/js/home.js
+++ b/public/js/home.js
@@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => {
const createPlanModal = document.getElementById('create-plan-modal');
async function loadTexts() {
- const response = await fetch('/api/texts');
+ const response = await fetch('/api/texts', {
+ credentials: 'same-origin',
+ });
const texts = await response.json();
textsList.innerHTML = texts
.map(text =>
@@ -67,8 +69,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 0388bf6..59fc4ee 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)
+ fetch('/api/texts/' + textId, { credentials: 'same-origin' })
.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)
+ return fetch('/api/nodes/' + textId, { credentials: 'same-origin' })
.then(res => res.json())
.then(nodes => {
const existing = document.querySelector('#text-detail > ul');
@@ -113,6 +113,7 @@ 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 => {
@@ -157,6 +158,7 @@ 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 1937749..029196d 100644
--- a/public/js/texts.js
+++ b/public/js/texts.js
@@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('texts-form');
async function loadTexts() {
- const res = await fetch('/api/texts');
+ const res = await fetch('/api/texts', {
+ credentials: 'same-origin',
+ });
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
new file mode 100644
index 0000000..31e649b
--- /dev/null
+++ b/tests/Fakes/FakeClock.php
@@ -0,0 +1,36 @@
+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
new file mode 100644
index 0000000..4e9a17d
--- /dev/null
+++ b/tests/Fakes/FakePasswordHasher.php
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..fbe8913
--- /dev/null
+++ b/tests/Fakes/FakeTokenGenerator.php
@@ -0,0 +1,25 @@
+callCount % count($this->predefinedTokens);
+ $this->callCount++;
+
+ return $this->predefinedTokens[$index];
+ }
+}
diff --git a/tests/Fakes/FakeUserRepository.php b/tests/Fakes/FakeUserRepository.php
index 062cae8..b2e766a 100644
--- a/tests/Fakes/FakeUserRepository.php
+++ b/tests/Fakes/FakeUserRepository.php
@@ -5,6 +5,7 @@ namespace Tests\Fakes;
use App\User\UseCases\CreateUserDto;
use App\User\User;
use App\User\UserRepository;
+use App\ValueObjects\EmailAddress;
class FakeUserRepository implements UserRepository
{
@@ -28,6 +29,28 @@ 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(),
);
}
@@ -37,6 +60,8 @@ 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
new file mode 100644
index 0000000..54eecd3
--- /dev/null
+++ b/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php
@@ -0,0 +1,118 @@
+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
new file mode 100644
index 0000000..8408384
--- /dev/null
+++ b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php
@@ -0,0 +1,161 @@
+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
new file mode 100644
index 0000000..a79eb9c
--- /dev/null
+++ b/tests/Unit/Auth/UseCases/CreateSessionTest.php
@@ -0,0 +1,85 @@
+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 667e72c..f88bc89 100644
--- a/tests/Unit/Plan/UseCases/CreatePlanTest.php
+++ b/tests/Unit/Plan/UseCases/CreatePlanTest.php
@@ -39,6 +39,7 @@ 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,
@@ -232,8 +233,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,
@@ -269,10 +270,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,
@@ -319,5 +320,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 d2bfc2b..477a07a 100644
--- a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php
+++ b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php
@@ -30,7 +30,12 @@ class CreateScheduledNodeTest extends TestCase
$this->planRepo = new FakePlanRepository();
$this->planRepo->create(new CreatePlanDto(
name: 'testplan',
- user: new User(0, new EmailAddress('test@test.com')),
+ user: new User(
+ id: 0,
+ email: new EmailAddress('test@test.com'),
+ passwordHash: 'hashed:password1',
+ isAdmin: false,
+ ),
));
$this->useCase = new CreateScheduledNode(
$this->scheduledNodeRepo,
diff --git a/tests/Unit/User/UseCases/AuthenticateUserTest.php b/tests/Unit/User/UseCases/AuthenticateUserTest.php
new file mode 100644
index 0000000..88bb1a9
--- /dev/null
+++ b/tests/Unit/User/UseCases/AuthenticateUserTest.php
@@ -0,0 +1,95 @@
+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 180046f..0333a52 100644
--- a/tests/Unit/User/UseCases/CreateUserTest.php
+++ b/tests/Unit/User/UseCases/CreateUserTest.php
@@ -6,33 +6,119 @@ 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
{
- $userRepo = new FakeUserRepository();
- $useCase = new CreateUser($userRepo);
- $useCase->execute(new CreateUserRequest(
+ $this->useCase->execute(new CreateUserRequest(
email: 'test@test.com',
+ password: 'password1',
+ isAdmin: false,
));
- $user = $userRepo->find(0);
+ $user = $this->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');
- $useCase->execute(new CreateUserRequest(
+ $this->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
new file mode 100644
index 0000000..50e39a5
--- /dev/null
+++ b/tests/e2e/Controllers/AuthControllerTest.php
@@ -0,0 +1,294 @@
+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 ee51012..cf0eeb0 100644
--- a/tests/e2e/Controllers/PlanControllerTest.php
+++ b/tests/e2e/Controllers/PlanControllerTest.php
@@ -8,6 +8,7 @@ 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;
@@ -29,6 +30,7 @@ class PlanControllerTest extends TestCase
private FakeScheduledNodeRepository $scheduledNodeRepo;
private CreatePlan $createPlan;
private PlanController $controller;
+ private User $user;
public function setUp(): void
{
@@ -38,8 +40,9 @@ class PlanControllerTest extends TestCase
$this->nodeRepo = new FakeNodeRepository();
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
- $this->userRepo->create(new CreateUserDto(
+ $this->user = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('test@test.com'),
+ passwordHash: '',
));
$text = $this->textRepo->create(new CreateTextDto('testname'));
$this->nodeRepo->create(new CreateNodeDto(
@@ -68,6 +71,7 @@ class PlanControllerTest extends TestCase
return new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/plans')
->withHeader('Content-Type', 'application/json')
+ ->withAttribute('user', $this->user)
->withBody($body);
}
@@ -75,7 +79,6 @@ class PlanControllerTest extends TestCase
{
$response = $this->controller->createPlan(
$this->makeRequest([
- 'userId' => 0,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
@@ -91,29 +94,33 @@ class PlanControllerTest extends TestCase
$this->assertEquals('My Plan', $body['name']);
}
- public function test_create_plan_returns_400_when_user_id_missing(): void
+ public function test_create_plan_returns_401_when_no_user(): 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(
- $this->makeRequest([
- 'textId' => 0,
- 'name' => 'My Plan',
- 'dateStart' => '2025-01-01',
- 'dateEnd' => '2025-01-01',
- ]),
+ $requestWithoutUser,
new Response(),
$this->createPlan,
);
- $this->assertEquals(400, $response->getStatusCode());
- $body = json_decode($response->getBody(), true);
- $this->assertArrayHasKey('error', $body);
+ $this->assertEquals(401, $response->getStatusCode());
}
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',
@@ -131,7 +138,6 @@ class PlanControllerTest extends TestCase
{
$response = $this->controller->createPlan(
$this->makeRequest([
- 'userId' => 0,
'textId' => 0,
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
@@ -149,7 +155,6 @@ class PlanControllerTest extends TestCase
{
$response = $this->controller->createPlan(
$this->makeRequest([
- 'userId' => 0,
'textId' => 0,
'name' => 'My Plan',
'dateEnd' => '2025-01-01',
@@ -167,7 +172,6 @@ class PlanControllerTest extends TestCase
{
$response = $this->controller->createPlan(
$this->makeRequest([
- 'userId' => 0,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
@@ -185,7 +189,6 @@ class PlanControllerTest extends TestCase
{
$response = $this->controller->createPlan(
$this->makeRequest([
- 'userId' => 0,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-02',
@@ -200,30 +203,10 @@ 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',
@@ -242,7 +225,6 @@ class PlanControllerTest extends TestCase
{
$this->controller->createPlan(
$this->makeRequest([
- 'userId' => 0,
'textId' => 0,
'name' => 'Persistent Plan',
'dateStart' => '2025-01-01',
@@ -261,7 +243,6 @@ 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 7f463d4..f1fc83d 100644
--- a/views/templates/admin.php
+++ b/views/templates/admin.php
@@ -4,6 +4,8 @@
Daily Goals - Admin
+
Texts
+