diff --git a/.gitignore b/.gitignore index 68bd285..f8b367e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ vendor/ node_modules/ data/*.json -.direnv/ \ No newline at end of file +.direnv/ +cypress/screenshots/ +cypress/videos/ \ No newline at end of file diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..e2342fc --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "caveman", + "caveman-opencode-plugin@latest" + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5dea88b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Project context + +Read these on every session. Rules in them override defaults. + +@ai/shared.md +@ai/backend-context.md +@ai/frontend-context.md diff --git a/DailyGoals.drawio b/DailyGoals.drawio index b0937cb..aa76945 100644 --- a/DailyGoals.drawio +++ b/DailyGoals.drawio @@ -16,11 +16,14 @@ + + + - + @@ -28,14 +31,14 @@ - - + + - + - + diff --git a/ai/backend-context.md b/ai/backend-context.md new file mode 100644 index 0000000..fca41f0 --- /dev/null +++ b/ai/backend-context.md @@ -0,0 +1,45 @@ +# Backend context + +> Read `ai/shared.md` first. This file only covers backend-specific rules. + +## Project Context + +**Stack:** PHP 8.5, Slim 4, PHP-DI/Slim-Bridge, PHPUnit 13, Composer. +Persistence is JSON-file based (see `Json*Repository` classes); no ORM. +**Architecture:** Domain-Driven Design. Code is organized by domain entity +under `app/` (Auth, Node, Plan, ScheduledNode, Text, User, View, +ValueObjects) into Entities, DTOs, Repositories, Use Cases, and Fakes +(in-memory repos for tests). + +## Code patterns + +- Look at similar entities (e.g. `Node`, `Text`) for reference +- Entities: constructor with properties, getters +- DTOs: simple data containers for creation (e.g. `CreateTextDto`) +- Repositories: interfaces that define data access + - Do not write unit tests for concrete repository implementations + (e.g. `JsonNodeRepository`). 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 + +## PHP rules + +- Imports: always put `use` statements at the top of the file, never use inline + imports (e.g. `\App\Foo\Bar::class`) +- Closures: never use arrow functions (`fn () =>`) - always use regular + anonymous functions (`function () { return ...; }`) + +## Pre-commit + +Run `php-cs-fixer fix` on worked-on directories before committing (uses the +existing `.php-cs-fixer.dist.php` config). diff --git a/ai/backend_prompt_template.md b/ai/backend_prompt_template.md deleted file mode 100644 index c16ef26..0000000 --- a/ai/backend_prompt_template.md +++ /dev/null @@ -1,68 +0,0 @@ -# Entity Creation Prompt Template - -Follow the existing patterns in this codebase to: -- create a new entity called [EntityName]. - -Requirements: -- The entity encapsulates [one or more Entities] -- Include [any other fields] - -Process (TDD - Test Driven Development): -1. Write a test first -2. Run the test to confirm it fails -3. Implement the code to make the test pass -4. Run the test to confirm it passes -5. Repeat for each new behavior - -Code patterns to follow: -- First, explore the codebase to understand existing entity patterns -- Look at similar entities (e.g. Node, Text, etc.) for reference -- 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 -- 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. - -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 - -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 -- Commits are for reviewing and documenting the development of code -- Don't wait to commit - commit as you go -- Run `php-cs-fixer fix` on worked on directories before committing - -Branch naming: -- Use kebab-case (e.g., presenting-track, agenda-slots) -- Use descriptive feature names -- Examples: "presenting-track", "agenda-slots", "confirm-application" -- Or use type/description: "feature/presenting-track", "fix/bug-name" -- NEVER work directly on master/main - always create and work on a branch - -Do not push anything. Make commits as you go. diff --git a/ai/frontend-context.md b/ai/frontend-context.md new file mode 100644 index 0000000..8757e1f --- /dev/null +++ b/ai/frontend-context.md @@ -0,0 +1,33 @@ +# Frontend context + +> Read `ai/shared.md` first. This file only covers frontend-specific rules. + +## Project Context + +**Stack:** vanilla PHP templates in `views/templates/`, plain ES JavaScript in +`public/js/`, no framework, no build step. Cypress 15 for E2E. +**Entry point:** `public/index.php` (Slim app); page templates are rendered +via the existing templating layer. + +## Code patterns + +- Look at existing pages (`home.php`/`home.js`, `text.php`/`text.js`, + `today.php`/`today.js`) for reference before writing anything +- **Templates:** `views/templates/.php`, one file per page +- **Page JS:** `public/js/.js`, one file per page, paired with the + matching template +- **Testing:** Cypress E2E only, mirror existing `cypress/e2e/*.cy.js` style + (note: this project uses `.cy.js`, not `.cy.ts`) +- **Imports / script tags:** keep at the top of the file +- **Variable names:** explicit, descriptive (e.g. `text` not `t`) + +## Pre-commit + +No JS formatter or linter is configured yet; format manually for consistency +with surrounding files. (TODO: wire up format/lint when added.) + +## Note on commit granularity + +Frontend changes are often a template plus its page-level JS counterpart - +commit them together as a single logical unit, per the "one logical change +per commit" rule in `shared.md`. diff --git a/ai/frontend_prompt_template.md b/ai/frontend_prompt_template.md deleted file mode 100644 index a865577..0000000 --- a/ai/frontend_prompt_template.md +++ /dev/null @@ -1,52 +0,0 @@ -# Frontend Prompt Template - -Follow the existing patterns in this codebase to: -- xxxxxxxx - -Requirements: -- xxxxx - -Process (TDD - Test Driven Development): -1. Write a test first -2. Run the test to confirm it fails -3. Implement the code to make the test pass -4. Run the test to confirm it passes -5. Repeat for each new behavior - -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 -- 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. - -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 - -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 -- Commits are for reviewing and documenting the development of code -- Don't wait to commit - commit as you go - -Branch naming: -- Use kebab-case (e.g., node-page text-page) -- Use descriptive feature names -- NEVER work directly on master - always create and work on a branch - -Do not push anything. Make commits as you go. diff --git a/ai/shared.md b/ai/shared.md new file mode 100644 index 0000000..cc85dd1 --- /dev/null +++ b/ai/shared.md @@ -0,0 +1,74 @@ +# Shared rules + +Rules that apply to both backend and frontend work in this repo. Stack-specific +guides (`backend-context.md`, `frontend-context.md`) extend these. + +## Process (TDD) + +0. Before editing any file, ensure you are on a feature branch + (`git status` to confirm). If on master/main, create a branch + first. +1. Write the test first +2. Run the test to confirm it fails +3. Commit the failing test (the "tests committed first" rule in + action - the test commit precedes the implementation commit, not + merely the implementation lines) +4. Implement the code to make the test pass +5. Run the test to confirm it passes +6. Commit the implementation +7. Repeat for each new behavior + +## Code style + +- Lines should not exceed 80 columns, but should use up to 80 columns when + possible - do not split lines unnecessarily +- Variable names: use explicit, descriptive names - never single-letter or + abbreviated variables (e.g. `$text` not `$t`, `$node` not `$n`) +- Method/function/constructor parameters: do not use default values - every + call site must pass every argument explicitly. This eliminates a class of + bugs where an unintended default silently slips through (e.g. an + `isAdmin=false` or an empty `passwordHash`). Apply the same rule in tests + and fakes - if a helper accepts a value, every caller must supply it. +- First, explore the codebase to understand existing patterns - look at similar + files for reference before writing anything +- Never use em dashes (—) in code, comments, or docblocks - use hyphens (-) + instead + +## Git commit style + +- Present tense, imperative mood (add, create, wire, fix, test) +- Lowercase +- Short (3-6 words) +- Match patterns found in git history +- Do not add any section mentioning claude as a coauthor +- Add a commit body when the subject alone cannot convey the change - e.g. + non-obvious motivation, multi-file coordination, or notable complexity +- Body: wrap at ~72 columns, separated from subject by a blank line, explain + the why and any non-obvious what +- Skip the body for trivial or self-explanatory commits + +## Git commits + +- Tests should be committed first, before implementation +- One logical change per commit - a commit may span multiple files when they + form a single logical unit (e.g. a use case with its request and exception, + or a template with its page-level JS) +- Keep commits focused: not one file per commit, not unrelated work batched +- Make commits frequent - commit each meaningful logical step as you go +- Commits are for reviewing and documenting the development of code +- When the formatter or linter modifies files outside your intended + change, either `git restore` them or land them as a separate + `format ` / `lint ` commit - never bundle drive-by + formatter churn into a feature commit +- If pre-commit lint fails on code you did not touch, do not bundle + the fix - either land the unrelated fix as its own commit first, or + note the pre-existing failure and proceed + +## Branching + +- Use kebab-case (e.g. `text-page`, `scheduled-node`, `auth-flow`) +- Use descriptive feature names +- Or use type/description: `feature/text-page`, `fix/bug-name` +- NEVER work directly on master/main - always create and work on a branch + +Do not push anything. Make commits as you go. diff --git a/app/Plan/JsonPlanRepository.php b/app/Plan/JsonPlanRepository.php index 8a88838..8c73d8a 100644 --- a/app/Plan/JsonPlanRepository.php +++ b/app/Plan/JsonPlanRepository.php @@ -90,4 +90,29 @@ class JsonPlanRepository implements PlanRepository return $maxId + 1; } + + public function findByUser(User $user): array + { + $plans = array_filter( + $this->readPlans(), + function ($data) use ($user) { + return $data['userId'] === $user->getId(); + } + ); + + return array_map( + function ($data) { + $user = $this->userRepository->find($data['userId']); + if ($user === null) { + return null; + } + return new Plan( + id: $data['id'], + name: $data['name'], + user: $user, + ); + }, + $plans + ); + } } diff --git a/app/Plan/PlanRepository.php b/app/Plan/PlanRepository.php index a962c0f..0585edc 100644 --- a/app/Plan/PlanRepository.php +++ b/app/Plan/PlanRepository.php @@ -2,8 +2,14 @@ namespace App\Plan; +use App\User\User; + interface PlanRepository { public function create(CreatePlanDto $dto): Plan; public function find(int $id): ?Plan; + /** + * @return Plan[] + */ + public function findByUser(User $user): array; } diff --git a/app/Plan/UseCases/CreatePlan.php b/app/Plan/UseCases/CreatePlan.php index b12fc50..8a3cc91 100644 --- a/app/Plan/UseCases/CreatePlan.php +++ b/app/Plan/UseCases/CreatePlan.php @@ -64,6 +64,7 @@ class CreatePlan new CreateScheduledNodeRequest( date: $scheduledDate->format('Y-m-d'), planId: $plan->getId(), + nodeId: $node->getId(), ) ); } diff --git a/app/ScheduledNode/CreateScheduledNodeDto.php b/app/ScheduledNode/CreateScheduledNodeDto.php index 5e7a695..086c975 100644 --- a/app/ScheduledNode/CreateScheduledNodeDto.php +++ b/app/ScheduledNode/CreateScheduledNodeDto.php @@ -2,6 +2,7 @@ namespace App\ScheduledNode; +use App\Node\Node; use App\Plan\Plan; use DateTimeImmutable; @@ -10,5 +11,6 @@ class CreateScheduledNodeDto public function __construct( public DateTimeImmutable $date, public Plan $plan, + public Node $node, ) {} } diff --git a/app/ScheduledNode/JsonScheduledNodeRepository.php b/app/ScheduledNode/JsonScheduledNodeRepository.php index bee31ea..1870844 100644 --- a/app/ScheduledNode/JsonScheduledNodeRepository.php +++ b/app/ScheduledNode/JsonScheduledNodeRepository.php @@ -2,12 +2,19 @@ namespace App\ScheduledNode; +use App\Node\NodeRepository; +use App\Plan\PlanRepository; +use App\User\User; +use DateTimeImmutable; + class JsonScheduledNodeRepository implements ScheduledNodeRepository { private string $filePath; - public function __construct() - { + public function __construct( + private PlanRepository $planRepo, + private NodeRepository $nodeRepo, + ) { $this->filePath = __DIR__ . '/../../data/scheduledNodes.json'; } @@ -20,6 +27,8 @@ class JsonScheduledNodeRepository implements ScheduledNodeRepository 'id' => $id, 'date' => $dto->date->format('Y-m-d'), 'planId' => $dto->plan->getId(), + 'nodeId' => $dto->node->getId(), + 'completed' => false, ]; $this->writeScheduledNodes($scheduledNodes); @@ -27,6 +36,8 @@ class JsonScheduledNodeRepository implements ScheduledNodeRepository id: $id, date: $dto->date, plan: $dto->plan, + node: $dto->node, + completed: false, ); } @@ -64,4 +75,34 @@ class JsonScheduledNodeRepository implements ScheduledNodeRepository return $maxId + 1; } + + public function findByUser(User $user): array + { + $allScheduledNodes = $this->readScheduledNodes(); + $planIds = array_map( + function ($plan) { + return $plan->getId(); + }, + $this->planRepo->findByUser($user) + ); + $usersScheduledNodes = array_filter( + $allScheduledNodes, + function ($node) use ($planIds) { + return in_array($node['planId'], $planIds); + } + ); + + return array_map( + function ($data) { + return new ScheduledNode( + id: $data['id'], + date: new DateTimeImmutable($data['date']), + plan: $this->planRepo->find($data['planId']), + node: $this->nodeRepo->find($data['nodeId']), + completed: $data['completed'] + ); + }, + $usersScheduledNodes + ); + } } diff --git a/app/ScheduledNode/ScheduledNode.php b/app/ScheduledNode/ScheduledNode.php index 7d479db..83f29ef 100644 --- a/app/ScheduledNode/ScheduledNode.php +++ b/app/ScheduledNode/ScheduledNode.php @@ -2,6 +2,7 @@ namespace App\ScheduledNode; +use App\Node\Node; use App\Plan\Plan; use DateTimeImmutable; @@ -11,6 +12,8 @@ class ScheduledNode private int $id, private DateTimeImmutable $date, private Plan $plan, + private Node $node, + private bool $completed, ) {} public function getId(): int @@ -27,4 +30,19 @@ class ScheduledNode { return $this->date; } + + public function getNode(): Node + { + return $this->node; + } + + public function getCompleted(): bool + { + return $this->completed; + } + + public function setCompleted(bool $complete): void + { + $this->completed = $complete; + } } diff --git a/app/ScheduledNode/ScheduledNodeController.php b/app/ScheduledNode/ScheduledNodeController.php new file mode 100644 index 0000000..3a71ccb --- /dev/null +++ b/app/ScheduledNode/ScheduledNodeController.php @@ -0,0 +1,73 @@ +getAttribute('user'); + if (!$user instanceof User) { + $response->getBody()->write( + json_encode(['error' => 'unauthenticated']) + ); + return $response->withStatus(401) + ->withHeader('Content-Type', 'application/json'); + } + + $queryParams = $request->getQueryParams(); + $date = $queryParams['date'] ?? null; + if ($date === '') { + $date = null; + } + + try { + $scheduledNodes = $getTodaysSchedule->execute( + new GetTodaysScheduleRequest( + date: $date, + userId: $user->getId(), + ) + ); + } catch (BadRequestException $exception) { + $response->getBody()->write( + json_encode(['error' => $exception->getMessage()]) + ); + return $response->withStatus(400) + ->withHeader('Content-Type', 'application/json'); + } catch (DomainException $exception) { + $response->getBody()->write( + json_encode(['error' => $exception->getMessage()]) + ); + return $response->withStatus(404) + ->withHeader('Content-Type', 'application/json'); + } + + $data = array_values(array_map( + function (ScheduledNode $scheduledNode) { + return [ + 'id' => $scheduledNode->getId(), + 'date' => $scheduledNode->getDate()->format('Y-m-d'), + 'planName' => $scheduledNode->getPlan()->getName(), + 'nodeTitle' => $scheduledNode->getNode()->getTitle(), + 'completed' => $scheduledNode->getCompleted(), + ]; + }, + $scheduledNodes, + )); + + $response->getBody()->write(json_encode($data)); + return $response->withStatus(200) + ->withHeader('Content-Type', 'application/json'); + } +} diff --git a/app/ScheduledNode/ScheduledNodeRepository.php b/app/ScheduledNode/ScheduledNodeRepository.php index b13b7c5..055a939 100644 --- a/app/ScheduledNode/ScheduledNodeRepository.php +++ b/app/ScheduledNode/ScheduledNodeRepository.php @@ -2,7 +2,13 @@ namespace App\ScheduledNode; +use App\User\User; + interface ScheduledNodeRepository { public function create(CreateScheduledNodeDto $dto): ScheduledNode; + /** + * @return ScheduledNode[] + */ + public function findByUser(User $user): array; } diff --git a/app/ScheduledNode/UseCases/CreateScheduledNode.php b/app/ScheduledNode/UseCases/CreateScheduledNode.php index 752b658..444f4d0 100644 --- a/app/ScheduledNode/UseCases/CreateScheduledNode.php +++ b/app/ScheduledNode/UseCases/CreateScheduledNode.php @@ -3,6 +3,7 @@ namespace App\ScheduledNode\UseCases; use App\Exceptions\BadRequestException; +use App\Node\NodeRepository; use App\Plan\PlanRepository; use App\ScheduledNode\ScheduledNode; use App\ScheduledNode\CreateScheduledNodeDto; @@ -15,6 +16,7 @@ class CreateScheduledNode public function __construct( private ScheduledNodeRepository $scheduledNodeRepo, private PlanRepository $planRepo, + private NodeRepository $nodeRepo, ) {} /** @@ -24,24 +26,40 @@ class CreateScheduledNode public function execute( CreateScheduledNodeRequest $request ): ScheduledNode { - if ($request->date === null) { + $nodeId = $request->nodeId; + $planId = $request->planId; + $date = $request->date; + if ($date === null) { throw new BadRequestException('date is required'); } - if ($request->planId === null) { + if ($planId === null) { throw new BadRequestException('planId is required'); } - $id = $request->planId; - $plan = $this->planRepo->find($id); + if ($nodeId === null) { + throw new BadRequestException('nodeId is required'); + } + + $plan = $this->planRepo->find($planId); if ($plan === null) { - throw new DomainException("Plan with id: $id doesnt exist"); + throw new DomainException( + "Plan with id: $planId doesnt exist" + ); + } + + $node = $this->nodeRepo->find($nodeId); + if ($node === null) { + throw new DomainException( + "Node with id: $nodeId doesnt exist" + ); } return $this->scheduledNodeRepo->create( new CreateScheduledNodeDto( - date: new DateTimeImmutable($request->date), + date: new DateTimeImmutable($date), plan: $plan, + node: $node, ) ); } diff --git a/app/ScheduledNode/UseCases/CreateScheduledNodeRequest.php b/app/ScheduledNode/UseCases/CreateScheduledNodeRequest.php index 5931cb3..4c0a2fd 100644 --- a/app/ScheduledNode/UseCases/CreateScheduledNodeRequest.php +++ b/app/ScheduledNode/UseCases/CreateScheduledNodeRequest.php @@ -7,5 +7,6 @@ class CreateScheduledNodeRequest public function __construct( public ?string $date, public ?int $planId, + public ?int $nodeId, ) {} } diff --git a/app/ScheduledNode/UseCases/GetTodaysSchedule.php b/app/ScheduledNode/UseCases/GetTodaysSchedule.php new file mode 100644 index 0000000..3301549 --- /dev/null +++ b/app/ScheduledNode/UseCases/GetTodaysSchedule.php @@ -0,0 +1,51 @@ +date === null) { + throw new BadRequestException('date is required'); + } + if ($request->userId === null) { + throw new BadRequestException('userId is required'); + } + $date = new DateTimeImmutable($request->date); + $userId = $request->userId; + $user = $this->userRepo->find($userId); + if ($user === null) { + throw new DomainException( + "User with id: $userId doesnt exist" + ); + } + $scheduledNodes = $this->scheduledNodeRepo->findByUser($user); + + return array_filter( + $scheduledNodes, + function (ScheduledNode $node) use ($date) { + return $node->getDate() <= $date + && $node->getCompleted() === false; + } + ); + } +} diff --git a/app/ScheduledNode/UseCases/GetTodaysScheduleRequest.php b/app/ScheduledNode/UseCases/GetTodaysScheduleRequest.php new file mode 100644 index 0000000..ccb38b8 --- /dev/null +++ b/app/ScheduledNode/UseCases/GetTodaysScheduleRequest.php @@ -0,0 +1,11 @@ +getBody()->write($html); + + return $response; + } + public function login(Response $response): Response { $html = file_get_contents( diff --git a/bootstrap/app.php b/bootstrap/app.php index c05dc0f..b4e9907 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,6 +11,7 @@ use App\View\ViewController; use App\Text\TextController; use App\Node\NodeController; use App\Plan\PlanController; +use App\ScheduledNode\ScheduledNodeController; $container = require __DIR__ . '/container.php'; $app = Bridge::create($container); @@ -27,6 +28,7 @@ $app->post('/api/auth/register', [AuthController::class, 'register']); // Authenticated routes (any logged-in user) $app->group('', function (RouteCollectorProxy $group) { $group->get('/home', [ViewController::class, 'home']); + $group->get('/today', [ViewController::class, 'today']); $group->post('/api/auth/logout', [AuthController::class, 'logout']); $group->get('/api/auth/me', [AuthController::class, 'me']); @@ -43,6 +45,11 @@ $app->group('', function (RouteCollectorProxy $group) { ); $group->post('/api/plans', [PlanController::class, 'createPlan']); + + $group->get( + '/api/scheduled-nodes', + [ScheduledNodeController::class, 'getScheduledNodes'] + ); })->add(AuthMiddleware::class); // Admin-only routes diff --git a/bootstrap/container.php b/bootstrap/container.php index 2ae6e4c..02666ed 100644 --- a/bootstrap/container.php +++ b/bootstrap/container.php @@ -26,10 +26,10 @@ $container = new Container([ NodeRepository::class => DI\autowire(JsonNodeRepository::class), PlanRepository::class => DI\autowire(JsonPlanRepository::class), UserRepository::class => DI\autowire(JsonUserRepository::class), - ScheduledNodeRepository::class => - DI\autowire(JsonScheduledNodeRepository::class), - SessionRepository::class => - DI\autowire(JsonSessionRepository::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), diff --git a/caveman.json b/caveman.json new file mode 100644 index 0000000..fb19545 --- /dev/null +++ b/caveman.json @@ -0,0 +1,9 @@ +{ + "enabled": true, + "defaultMode": "full", + "features": { + "caveman": true, + "commit": , + "review": true + } +} diff --git a/cypress/e2e/today.cy.js b/cypress/e2e/today.cy.js new file mode 100644 index 0000000..1f24450 --- /dev/null +++ b/cypress/e2e/today.cy.js @@ -0,0 +1,75 @@ +describe('The today page', () => { + beforeEach(() => { + cy.exec('npm run db:seed') + }) + + afterEach(() => { + cy.exec('npm run db:wipe') + }) + + it('redirects to login when not authenticated', () => { + cy.visit('/today') + cy.url().should('include', '/login') + }) + + it('displays a Today heading when authenticated', () => { + cy.loginAsUser() + cy.visit('/today') + cy.get('h1').should('contain', 'Today') + }) + + it('has a list element for scheduled nodes', () => { + cy.loginAsUser() + cy.visit('/today') + cy.get('#scheduled-nodes-list').should('exist') + }) + + it('home page links to the today page', () => { + cy.loginAsUser() + cy.visit('/home') + cy.get('a[href="/today"]').should('be.visible') + }) + + it('lists scheduled nodes for today', () => { + const today = new Date() + const year = today.getFullYear() + const month = String(today.getMonth() + 1).padStart(2, '0') + const day = String(today.getDate()).padStart(2, '0') + const todayString = year + '-' + month + '-' + day + + cy.loginAsUser() + cy.request({ + method: 'POST', + url: '/api/plans', + body: { + textId: 0, + name: 'My reading plan', + dateStart: todayString, + dateEnd: todayString, + }, + }) + + cy.intercept('GET', '/api/scheduled-nodes*') + .as('getScheduledNodes') + cy.visit('/today') + cy.wait('@getScheduledNodes').then((interception) => { + expect(interception.request.url).to.include( + 'date=' + todayString + ) + }) + cy.get('#scheduled-nodes-list').should( + 'contain', + 'My reading plan' + ) + cy.get('#scheduled-nodes-list').should('contain', 'Bereishis') + }) + + it('shows an empty list when no nodes are scheduled today', () => { + cy.loginAsUser() + cy.intercept('GET', '/api/scheduled-nodes*') + .as('getScheduledNodes') + cy.visit('/today') + cy.wait('@getScheduledNodes') + cy.get('#scheduled-nodes-list li').should('have.length', 0) + }) +}) diff --git a/public/js/today.js b/public/js/today.js new file mode 100644 index 0000000..6f14627 --- /dev/null +++ b/public/js/today.js @@ -0,0 +1,33 @@ +document.addEventListener('DOMContentLoaded', () => { + const scheduledNodesList = document.getElementById( + 'scheduled-nodes-list' + ); + + function todayDateString() { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + return year + '-' + month + '-' + day; + } + + async function loadScheduledNodes() { + const date = todayDateString(); + const response = await fetch( + '/api/scheduled-nodes?date=' + date, + { credentials: 'same-origin' } + ); + if (!response.ok) { + return; + } + const scheduledNodes = await response.json(); + scheduledNodesList.innerHTML = scheduledNodes + .map((scheduledNode) => + '
  • ' + scheduledNode.planName + ': ' + + scheduledNode.nodeTitle + '
  • ' + ) + .join(''); + } + + loadScheduledNodes(); +}); diff --git a/tests/Fakes/FakePlanRepository.php b/tests/Fakes/FakePlanRepository.php index 45e2e85..8c18b00 100644 --- a/tests/Fakes/FakePlanRepository.php +++ b/tests/Fakes/FakePlanRepository.php @@ -5,9 +5,13 @@ namespace Tests\Fakes; use App\Plan\CreatePlanDto; use App\Plan\Plan; use App\Plan\PlanRepository; +use App\User\User; class FakePlanRepository implements PlanRepository { + /** + * @var Plan[] + */ private array $existingPlans = []; public function create(CreatePlanDto $dto): Plan @@ -37,4 +41,24 @@ class FakePlanRepository implements PlanRepository } ); } + + public function findByUser(User $user): array + { + $plans = array_filter( + $this->existingPlans, + function (Plan $plan) use ($user) { + return $plan->getUser()->getId() === $user->getId(); + } + ); + return array_map( + function (Plan $plan) { + return new Plan( + id: $plan->getId(), + name: $plan->getName(), + user: $plan->getUser(), + ); + }, + $plans + ); + } } diff --git a/tests/Fakes/FakeScheduledNodeRepository.php b/tests/Fakes/FakeScheduledNodeRepository.php index 4241813..1125b51 100644 --- a/tests/Fakes/FakeScheduledNodeRepository.php +++ b/tests/Fakes/FakeScheduledNodeRepository.php @@ -5,6 +5,7 @@ namespace Tests\Fakes; use App\ScheduledNode\CreateScheduledNodeDto; use App\ScheduledNode\ScheduledNode; use App\ScheduledNode\ScheduledNodeRepository; +use App\User\User; class FakeScheduledNodeRepository implements ScheduledNodeRepository { @@ -20,12 +21,27 @@ class FakeScheduledNodeRepository implements ScheduledNodeRepository id: $id, date: $dto->date, plan: $dto->plan, + node: $dto->node, + completed: false, ); $this->existingScheduledNodes[$id] = $scheduledNode; return $scheduledNode; } + public function update(ScheduledNode $node): ScheduledNode + { + $this->existingScheduledNodes[$node->getId()] = $node; + + return new ScheduledNode( + id: $node->getId(), + date: $node->getDate(), + plan: $node->getPlan(), + node: $node->getNode(), + completed: $node->getCompleted() + ); + } + public function find(int $id): ?ScheduledNode { return array_find( @@ -45,4 +61,27 @@ class FakeScheduledNodeRepository implements ScheduledNodeRepository { return count($this->existingScheduledNodes); } + + public function findByUser(User $user): array + { + $scheduledNodes = array_filter( + $this->existingScheduledNodes, + function (ScheduledNode $node) use ($user) { + return $node->getPlan()->getUser()->getId() === $user->getId(); + } + ); + + return array_map( + function (ScheduledNode $node) { + return new ScheduledNode( + id: $node->getId(), + date: $node->getDate(), + plan: $node->getPlan(), + node: $node->getNode(), + completed: $node->getCompleted(), + ); + }, + $scheduledNodes, + ); + } } diff --git a/tests/Unit/Plan/UseCases/CreatePlanTest.php b/tests/Unit/Plan/UseCases/CreatePlanTest.php index 94412b7..fbdfc60 100644 --- a/tests/Unit/Plan/UseCases/CreatePlanTest.php +++ b/tests/Unit/Plan/UseCases/CreatePlanTest.php @@ -45,6 +45,7 @@ class CreatePlanTest extends TestCase $this->createScheduledNode = new CreateScheduledNode( scheduledNodeRepo: $this->scheduledNodeRepo, planRepo: $this->planRepo, + nodeRepo: $this->nodeRepo, ); $this->textRepo->create(new CreateTextDto('testname')); $this->useCase = new CreatePlan( diff --git a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php index 477a07a..8c866fe 100644 --- a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php +++ b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php @@ -3,15 +3,19 @@ namespace Tests\Unit\ScheduledNode\UseCases; use App\Exceptions\BadRequestException; +use App\Node\CreateNodeDto; +use App\Node\Node; use App\Plan\CreatePlanDto; use App\Plan\Plan; use App\ScheduledNode\ScheduledNode; use App\ScheduledNode\ScheduledNodeRepository; use App\ScheduledNode\UseCases\CreateScheduledNode; use App\ScheduledNode\UseCases\CreateScheduledNodeRequest; +use App\Text\Text; use App\User\User; use App\ValueObjects\EmailAddress; use DomainException; +use Tests\Fakes\FakeNodeRepository; use Tests\Fakes\FakePlanRepository; use Tests\Fakes\FakeScheduledNodeRepository; use PHPUnit\Framework\TestCase; @@ -22,12 +26,20 @@ class CreateScheduledNodeTest extends TestCase private FakePlanRepository $planRepo; + private FakeNodeRepository $nodeRepo; + private CreateScheduledNode $useCase; public function setUp(): void { $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); $this->planRepo = new FakePlanRepository(); + $this->nodeRepo = new FakeNodeRepository(); + $this->nodeRepo->create(new CreateNodeDto( + text: new Text(0, 'text name'), + title: 'test node', + parentNode: null, + )); $this->planRepo->create(new CreatePlanDto( name: 'testplan', user: new User( @@ -40,6 +52,7 @@ class CreateScheduledNodeTest extends TestCase $this->useCase = new CreateScheduledNode( $this->scheduledNodeRepo, $this->planRepo, + $this->nodeRepo, ); } @@ -49,6 +62,7 @@ class CreateScheduledNodeTest extends TestCase new CreateScheduledNodeRequest( date: '2025-01-01', planId: 0, + nodeId: 0, ) ); $this->assertInstanceOf(ScheduledNode::class, $scheduledNode); @@ -64,11 +78,24 @@ class CreateScheduledNodeTest extends TestCase new CreateScheduledNodeRequest( date: '2025-01-01', planId: 0, + nodeId: 0 ) ); $this->assertInstanceOf(Plan::class, $scheduledNode->getPlan()); } + public function test_scheduled_node_belongs_to_node(): void + { + $scheduledNode = $this->useCase->execute( + new CreateScheduledNodeRequest( + date: '2025-01-01', + planId: 0, + nodeId: 0 + ) + ); + $this->assertInstanceOf(Node::class, $scheduledNode->getNode()); + } + public function test_nonexistant_plan_throws(): void { $this->expectException(DomainException::class); @@ -77,6 +104,20 @@ class CreateScheduledNodeTest extends TestCase new CreateScheduledNodeRequest( date: '2025-01-01', planId: 1, + nodeId: 0, + ) + ); + } + + public function test_nonexistant_node_throws(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Node with id: 1 doesnt exist'); + $this->useCase->execute( + new CreateScheduledNodeRequest( + date: '2025-01-01', + planId: 0, + nodeId: 1, ) ); } @@ -90,6 +131,7 @@ class CreateScheduledNodeTest extends TestCase new CreateScheduledNodeRequest( date: null, planId: 0, + nodeId: 0 ) ); } @@ -103,6 +145,21 @@ class CreateScheduledNodeTest extends TestCase new CreateScheduledNodeRequest( date: '2025-01-01', planId: null, + nodeId: 0, + ) + ); + } + + public function test_throws_if_node_id_is_null(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('nodeId is required'); + + $this->useCase->execute( + new CreateScheduledNodeRequest( + date: '2025-01-01', + planId: 0, + nodeId: null, ) ); } diff --git a/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php b/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php new file mode 100644 index 0000000..359b632 --- /dev/null +++ b/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php @@ -0,0 +1,222 @@ +userRepo = new FakeUserRepository(); + $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); + $this->planRepo = new FakePlanRepository(); + $user = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('email@email.com'), + passwordHash: 'hash', + isAdmin: false, + )); + $plan = $this->planRepo->create(new CreatePlanDto( + name: 'test plan', + user: $user, + )); + $this->scheduledNodeRepo->create(new CreateScheduledNodeDto( + date: new DateTimeImmutable('2025-01-02'), + plan: $plan, + node: new Node( + id: 0, + title: 'test node', + text: new Text(id: 0, name: 'test text'), + parentNode: null, + ), + )); + + $this->useCase = new GetTodaysSchedule( + userRepo: $this->userRepo, + scheduledNodeRepo: $this->scheduledNodeRepo, + ); + } + + public function test_returns_array_of_scheduled_nodes(): void + { + $result = $this->useCase->execute(new GetTodaysScheduleRequest( + date: '2025-01-02', + userId: 0, + )); + + $this->assertIsArray($result); + $this->assertInstanceOf(ScheduledNode::class, $result[0]); + } + + public function test_returns_all_unfinished_scheduled_nodes_up_until_today(): void + { + $this->scheduledNodeRepo->create(new CreateScheduledNodeDto( + date: new DateTimeImmutable('2025-01-01'), + plan: $this->planRepo->find(0), + node: new Node( + id: 0, + title: 'test node', + text: new Text(id: 0, name: 'test text'), + parentNode: null, + ), + )); + $result = $this->useCase->execute(new GetTodaysScheduleRequest( + date: '2025-01-02', + userId: 0, + )); + $this->assertEquals(2, count($result)); + } + + public function test_only_returns_uncompleted_nodes(): void + { + $node = $this->scheduledNodeRepo->create( + new CreateScheduledNodeDto( + date: new DateTimeImmutable('2025-01-01'), + plan: $this->planRepo->find(0), + node: new Node( + id: 0, + title: 'test node', + text: new Text(id: 0, name: 'test text'), + parentNode: null, + ), + ) + ); + $node->setCompleted(true); + $this->scheduledNodeRepo->update($node); + + $result = $this->useCase->execute(new GetTodaysScheduleRequest( + date: '2025-01-02', + userId: 0, + )); + $this->assertEquals(1, count($result)); + } + + public function test_throws_if_date_is_null(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('date is required'); + + $this->useCase->execute(new GetTodaysScheduleRequest( + date: null, + userId: 0, + )); + } + + public function test_throws_if_user_id_is_null(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('userId is required'); + + $this->useCase->execute(new GetTodaysScheduleRequest( + date: '2025-01-02', + userId: null, + )); + } + + public function test_nonexistant_user_throws(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('User with id: 99 doesnt exist'); + + $this->useCase->execute(new GetTodaysScheduleRequest( + date: '2025-01-02', + userId: 99, + )); + } + + public function test_returns_empty_array_when_user_has_no_scheduled_nodes(): void + { + $otherUser = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('other@email.com'), + passwordHash: 'hash', + isAdmin: false, + )); + + $result = $this->useCase->execute(new GetTodaysScheduleRequest( + date: '2025-01-02', + userId: $otherUser->getId(), + )); + + $this->assertIsArray($result); + $this->assertEquals(0, count($result)); + } + + public function test_excludes_scheduled_nodes_dated_after_today(): void + { + $this->scheduledNodeRepo->create(new CreateScheduledNodeDto( + date: new DateTimeImmutable('2025-01-05'), + plan: $this->planRepo->find(0), + node: new Node( + id: 0, + title: 'future node', + text: new Text(id: 0, name: 'test text'), + parentNode: null, + ), + )); + + $result = $this->useCase->execute(new GetTodaysScheduleRequest( + date: '2025-01-02', + userId: 0, + )); + + $this->assertEquals(1, count($result)); + } + + public function test_does_not_return_other_users_scheduled_nodes(): void + { + $otherUser = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('other@email.com'), + passwordHash: 'hash', + isAdmin: false, + )); + $otherPlan = $this->planRepo->create(new CreatePlanDto( + name: 'other plan', + user: $otherUser, + )); + $this->scheduledNodeRepo->create(new CreateScheduledNodeDto( + date: new DateTimeImmutable('2025-01-02'), + plan: $otherPlan, + node: new Node( + id: 0, + title: 'other node', + text: new Text(id: 0, name: 'test text'), + parentNode: null, + ), + )); + + $result = $this->useCase->execute(new GetTodaysScheduleRequest( + date: '2025-01-02', + userId: 0, + )); + + $this->assertEquals(1, count($result)); + $resultNode = array_values($result)[0]; + $this->assertEquals( + 0, + $resultNode->getPlan()->getUser()->getId() + ); + } +} diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index eb34ecd..818651c 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -55,6 +55,7 @@ class PlanControllerTest extends TestCase $createScheduledNode = new CreateScheduledNode( scheduledNodeRepo: $this->scheduledNodeRepo, planRepo: $this->planRepo, + nodeRepo: $this->nodeRepo, ); $this->createPlan = new CreatePlan( $this->planRepo, diff --git a/tests/e2e/Controllers/ScheduledNodeControllerTest.php b/tests/e2e/Controllers/ScheduledNodeControllerTest.php new file mode 100644 index 0000000..0a7c57e --- /dev/null +++ b/tests/e2e/Controllers/ScheduledNodeControllerTest.php @@ -0,0 +1,252 @@ +userRepo = new FakeUserRepository(); + $this->planRepo = new FakePlanRepository(); + $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); + + $this->user = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('test@test.com'), + passwordHash: '', + isAdmin: false, + )); + + $this->getTodaysSchedule = new GetTodaysSchedule( + userRepo: $this->userRepo, + scheduledNodeRepo: $this->scheduledNodeRepo, + ); + $this->controller = new ScheduledNodeController(); + } + + private function makeRequest( + ?string $date, + ?User $user, + ): ServerRequestInterface { + $request = new ServerRequestFactory() + ->createServerRequest( + 'GET', + 'http://localhost/api/scheduled-nodes' + ); + if ($user !== null) { + $request = $request->withAttribute('user', $user); + } + if ($date !== null) { + $request = $request->withQueryParams(['date' => $date]); + } + return $request; + } + + private function seedScheduledNode( + User $user, + string $date, + string $planName, + string $nodeTitle, + ): void { + $plan = $this->planRepo->create(new CreatePlanDto( + name: $planName, + user: $user, + )); + $this->scheduledNodeRepo->create(new CreateScheduledNodeDto( + date: new DateTimeImmutable($date), + plan: $plan, + node: new Node( + id: 0, + title: $nodeTitle, + text: new Text(id: 0, name: 'test text'), + parentNode: null, + ), + )); + } + + public function test_returns_200_with_scheduled_nodes_for_user(): void + { + $this->seedScheduledNode( + $this->user, + '2025-01-02', + 'My reading plan', + 'Bereishis', + ); + + $response = $this->controller->getScheduledNodes( + $this->makeRequest('2025-01-02', $this->user), + new Response(), + $this->getTodaysSchedule, + ); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertIsArray($body); + $this->assertCount(1, $body); + } + + public function test_response_has_expected_fields(): void + { + $this->seedScheduledNode( + $this->user, + '2025-01-02', + 'My reading plan', + 'Bereishis', + ); + + $response = $this->controller->getScheduledNodes( + $this->makeRequest('2025-01-02', $this->user), + new Response(), + $this->getTodaysSchedule, + ); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('id', $body[0]); + $this->assertArrayHasKey('date', $body[0]); + $this->assertEquals('2025-01-02', $body[0]['date']); + $this->assertEquals('My reading plan', $body[0]['planName']); + $this->assertEquals('Bereishis', $body[0]['nodeTitle']); + $this->assertEquals(false, $body[0]['completed']); + } + + public function test_returns_401_when_no_user_attribute(): void + { + $response = $this->controller->getScheduledNodes( + $this->makeRequest('2025-01-02', null), + new Response(), + $this->getTodaysSchedule, + ); + + $this->assertEquals(401, $response->getStatusCode()); + } + + public function test_returns_400_when_date_query_param_missing(): void + { + $response = $this->controller->getScheduledNodes( + $this->makeRequest(null, $this->user), + new Response(), + $this->getTodaysSchedule, + ); + + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('error', $body); + $this->assertEquals('date is required', $body['error']); + } + + public function test_returns_400_when_date_query_param_empty_string(): void + { + $response = $this->controller->getScheduledNodes( + $this->makeRequest('', $this->user), + new Response(), + $this->getTodaysSchedule, + ); + + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('error', $body); + $this->assertEquals('date is required', $body['error']); + } + + public function test_excludes_future_scheduled_nodes(): void + { + $this->seedScheduledNode( + $this->user, + '2025-01-10', + 'My reading plan', + 'Bereishis', + ); + + $response = $this->controller->getScheduledNodes( + $this->makeRequest('2025-01-02', $this->user), + new Response(), + $this->getTodaysSchedule, + ); + + $body = json_decode((string) $response->getBody(), true); + $this->assertCount(0, $body); + } + + public function test_excludes_completed_scheduled_nodes(): void + { + $this->seedScheduledNode( + $this->user, + '2025-01-02', + 'My reading plan', + 'Bereishis', + ); + $stored = $this->scheduledNodeRepo->find(0); + $stored->setCompleted(true); + $this->scheduledNodeRepo->update($stored); + + $response = $this->controller->getScheduledNodes( + $this->makeRequest('2025-01-02', $this->user), + new Response(), + $this->getTodaysSchedule, + ); + + $body = json_decode((string) $response->getBody(), true); + $this->assertCount(0, $body); + } + + public function test_returns_empty_array_when_user_has_no_nodes(): void + { + $response = $this->controller->getScheduledNodes( + $this->makeRequest('2025-01-02', $this->user), + new Response(), + $this->getTodaysSchedule, + ); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertEquals([], $body); + } + + public function test_returns_404_when_use_case_throws_domain_exception(): void + { + $unknownUser = new User( + id: 999, + email: new EmailAddress('ghost@test.com'), + passwordHash: '', + isAdmin: false, + ); + + $response = $this->controller->getScheduledNodes( + $this->makeRequest('2025-01-02', $unknownUser), + new Response(), + $this->getTodaysSchedule, + ); + + $this->assertEquals(404, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('error', $body); + $this->assertEquals( + 'User with id: 999 doesnt exist', + $body['error'] + ); + } +} diff --git a/views/templates/home.php b/views/templates/home.php index 4f7a096..258a404 100644 --- a/views/templates/home.php +++ b/views/templates/home.php @@ -6,6 +6,7 @@

    Home

    + Today's schedule