Compare commits

..

24 commits

Author SHA1 Message Date
3b29c7b90f
Merge branch 'text-has-user' 2026-05-02 22:40:39 +03:00
24d01b5908
text has user update in drawio 2026-05-02 22:40:18 +03:00
507922bf55
add cy request anti pattern 2026-05-02 22:40:09 +03:00
d97c2cdf07
update todos 2026-05-02 22:40:00 +03:00
cebed26cde
require user arg in bulk node test helper 2026-05-02 22:22:21 +03:00
5d6c9f7ec9
add llm anti-patterns to context files
name the LLM-default constructs this project forbids in
explicit before/after tables. catching the trap by pattern
match is more reliable than expecting a general rule to be
applied at write time. backend table covers PHP traps
(arrow fns, inline FQCNs, default params, stored refs, em
dashes, short names); frontend table covers JS/template/
cypress traps.
2026-05-02 22:15:23 +03:00
b07b1e2666
add session start protocol and pre-commit checklist
AGENTS.md gains a non-negotiable session start protocol that
forces reading the context files and checking the current
branch before any edits. shared.md gains a pre-commit
checklist covering branch/scope, code rules, mechanical
checks, and commit metadata. both additions exist because
this branch's history shows what happens when the rules are
treated as background information rather than active
checklists.
2026-05-02 22:14:54 +03:00
db93871194
move inline use statements to file headers
two type hints introduced earlier on this branch referenced
classes by their fully-qualified names inline. hoist them to
the top-of-file use block per backend-context.md PHP rules.
2026-05-02 22:05:58 +03:00
3a1e91cc4f
scope user text child-add cypress selectors
the seeded text already has nested nodes, so 'li.first()'
matched multiple buttons. scope the selectors to top-level
li children to match the working pattern in adminText.cy.js.
2026-05-02 21:58:34 +03:00
c065e065e9
fix admin texts route shadow conflict
FastRoute rejected /api/texts/all because the previously
declared variable route /api/texts/{textId} would shadow it,
crashing the app on boot. move the admin all-texts endpoint
to /api/admin/texts to clear the conflict; admin texts.js
follows the new URL.
2026-05-02 21:54:43 +03:00
71e5fb8fda
add cypress coverage for user text pages
loginAsSecondUser helper backs new specs that cover the
/texts list (own-only scoping, create form, link to
/texts/{id}) and /texts/{id} detail (own access, 403 on
another user's text, owner can add a child node).
2026-05-02 21:47:20 +03:00
6d11f7e887
add user texts and text detail pages
new /texts page lets a user manage their own texts (list +
create form linking to /texts/{id}); /texts/{id} reuses
text.js for the node tree, with a back link to /texts. home
gains a 'My texts' link in the header. the admin texts page
now sources its cross-user list from /api/texts/all.
2026-05-02 21:46:41 +03:00
7473af4163
enforce text ownership on node endpoints
getNodesOfText, createNode, and bulkCreateNodes now require
the session user, look up the target text, and respond 403
unless the user owns the text or is an admin. paves the way
for moving these endpoints out of the admin-only group.
2026-05-02 21:45:47 +03:00
e56cb56ce7
test node controller ownership checks
add failing tests asserting 403 when a non-owner tries to
read or write nodes on another user's text, plus admin
bypass. existing tests now attach a session user to mirror
the new controller signature.
2026-05-02 21:45:15 +03:00
051e44033f
wire user texts routes and update seed
open POST /api/texts and node create endpoints to any
authenticated user; expose new /texts and /texts/{id} pages
plus admin-only GET /api/texts/all. ViewController gains
userTexts and userText methods. seed gives Tanach to the
regular user and adds a second non-admin user.
2026-05-02 21:43:48 +03:00
acdf703d80
scope text endpoints by ownership
TextRepository gains findByUser; JsonTextRepository and the
fake implement filtering by stored userId. TextController
splits the list endpoint into getMyTexts (own) and
getAllTexts (admin), and getText now requires the session
user, returning 403 to non-owners while admins bypass.
2026-05-02 21:42:51 +03:00
ea6d65a77d
test text controller scoping and ownership
add failing tests for getMyTexts (own-only), getAllTexts
(admin), getText 403 for non-owner, and admin bypass on
getText. existing test_get_one_text updated to pass the
session user via the new request signature.
2026-05-02 21:41:52 +03:00
cbbbc80326
update downstream tests for text user requirement
Text now requires a User on construction. seed a user in
each test setUp that creates a Text directly or through the
fake repository so the suite remains green.
2026-05-02 21:27:55 +03:00
40fdf25da2
add tests for text user relationship
cover that the created Text carries the supplied User, that
the controller persists the user from the session attribute,
and that any userId in the request body is ignored.
2026-05-02 21:27:49 +03:00
6668240126
update fake text repository for user
include the user when rebuilding Text instances in find and
getAll, preserving the rule that lookup methods return new
instances rather than stored references.
2026-05-02 21:27:45 +03:00
4635fef3c7
persist user id in json text repository
store userId in the json record and rehydrate the User via
UserRepository. throws DomainException if the referenced user
no longer exists.
2026-05-02 21:27:40 +03:00
bac8323806
extract user from session in text controller
prevent payload from spoofing ownership by reading the user
from the request attribute set by auth middleware. respond 401
when unauthenticated.
2026-05-02 21:27:36 +03:00
bf006220e8
pass user object to create text use case
drop UserRepository dependency; controller now passes the
authenticated User directly via CreateTextRequest, eliminating
a redundant repository lookup.
2026-05-02 21:27:32 +03:00
ffef0ddff6
add user property to text entity 2026-05-02 21:27:28 +03:00
37 changed files with 1270 additions and 167 deletions

View file

@ -5,3 +5,21 @@ Read these on every session. Rules in them override defaults.
@ai/shared.md
@ai/backend-context.md
@ai/frontend-context.md
## Session start protocol
Before responding to the first user message in a session, you MUST:
1. Read `ai/shared.md`, `ai/backend-context.md`, `ai/frontend-context.md` in
full. Do not skim. Do not skip on the assumption they were read in a
prior session - context is not preserved.
2. Run `git status` and `git branch --show-current`. If on `master` or
`main`, do NOT make any edits until a feature branch exists, even if
the user's first message looks like a quick read-only question. Many
"quick questions" turn into edits.
3. Confirm in your first response that the rules were read and the branch
was checked. Do not narrate the contents - just acknowledge.
Skipping this protocol caused real bugs and rework in past sessions
(work landed on master, TDD order was lost, formatter not run, banned
constructs slipped in). Treat the protocol as non-negotiable.

View file

@ -13,17 +13,20 @@
<mxCell id="UlVOh7WOaItsqOB8hf6W-2" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Plan" vertex="1">
<mxGeometry height="80" width="80" x="450" y="290" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-7" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-2">
<mxCell id="UlVOh7WOaItsqOB8hf6W-23" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-9">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-21" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-9">
<mxCell id="UlVOh7WOaItsqOB8hf6W-24" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-2">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-5" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Scheduled Node" vertex="1">
<mxGeometry height="80" width="80" x="610" y="290" as="geometry" />
<mxGeometry height="80" width="80" x="450" y="140" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-22" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-8" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Text" vertex="1">
<mxGeometry height="80" width="80" x="450" y="90" as="geometry" />
<mxGeometry height="80" width="80" x="290" y="10" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-12" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="UlVOh7WOaItsqOB8hf6W-8">
<mxGeometry relative="1" as="geometry" />
@ -31,14 +34,14 @@
<mxCell id="UlVOh7WOaItsqOB8hf6W-14" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.25;exitDx=0;exitDy=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-9" value="">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="800" y="110" />
<mxPoint x="800" y="150" />
<mxPoint x="640" y="30" />
<mxPoint x="640" y="70" />
</Array>
<mxPoint x="800" y="150" as="targetPoint" />
<mxPoint x="640" y="70" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-9" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Node" vertex="1">
<mxGeometry height="80" width="80" x="610" y="90" as="geometry" />
<mxGeometry height="80" width="80" x="450" y="10" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-19" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-1">
<mxGeometry relative="1" as="geometry" />

View file

@ -1,5 +1,6 @@
# Set a goal for finishing a book by a specific date and have your daily goals automatically calculated
# Set a goal for finishing a set by a specific date and have your daily goals automatically calculated
# TODO
- Test Email Address Value Object
- Move create text out of view controller into text controller
- Test Email Address Value Object
- Reevaluate validation in node controller what needs to be moved into use cases.
- checkTextOwnership is definitely business logic

View file

@ -43,3 +43,22 @@ ValueObjects) into Entities, DTOs, Repositories, Use Cases, and Fakes
Run `php-cs-fixer fix` on worked-on directories before committing (uses the
existing `.php-cs-fixer.dist.php` config).
## LLM anti-patterns
Constructs LLMs default to that this project forbids. Name the trap
explicitly so you catch yourself before writing it.
| Anti-pattern | Forbidden | Required |
|---|---|---|
| Arrow function | `fn ($x) => $x->getId()` | `function ($x) { return $x->getId(); }` |
| Inline FQCN type | `function f(): \Psr\Http\Message\ResponseInterface` | `use Psr\Http\Message\ResponseInterface;` then `function f(): ResponseInterface` |
| Inline `::class` | `Container::get(\App\Foo\Bar::class)` | `use App\Foo\Bar;` then `Container::get(Bar::class)` |
| Default param | `function f(int $count = 10)` | `function f(int $count)` |
| Default in fake | `public function create(Dto $dto, bool $strict = true)` | no default; every caller passes a value |
| Lookup returns stored ref | `return $this->items[$id] ?? null;` | rebuild a new instance with the stored fields |
| Short variable name | `$t`, `$n`, `$res`, `$req`, `$e` | `$text`, `$node`, `$response`, `$request`, `$exception` |
| Em dash | `// fetches user — by email` | `// fetches user - by email` |
When generating code, scan the diff for these patterns before writing it
to disk. If you catch one mid-write, rewrite that line.

View file

@ -31,3 +31,19 @@ with surrounding files. (TODO: wire up format/lint when added.)
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`.
## LLM anti-patterns
Constructs LLMs default to that this project forbids on the frontend.
| Anti-pattern | Forbidden | Required |
|---|---|---|
| Short variable name | `t`, `n`, `res`, `req`, `e`, `el`, `ev` | `text`, `node`, `response`, `request`, `submitEvent`, `element`, `clickEvent` |
| Em dash in code/comments | `// loads texts — owner only` | `// loads texts - owner only` |
| Inline `<script>` in templates | `<script>doStuff()</script>` in a `.php` template | put logic in `public/js/<page>.js`, load via `<script src=...>` |
| Hardcoded admin URLs in user-facing JS | `fetch('/api/admin/...')` from a non-admin page JS | call user-scoped endpoints from user pages, admin endpoints only from admin pages |
| Cypress test logging in as the wrong role | `cy.loginAsAdmin()` in a non-admin spec | match the role to the page under test (`loginAsUser` for `/home`, `/texts`; `loginAsAdmin` for `/admin/*`) |
| `cy.request` in E2E tests | `cy.request('/api/...')` to set up state or assert | tests must exercise UI - drive via `cy.visit`/`cy.get`; if seeding is needed, add it to backend seed data |
When generating code, scan the diff for these patterns before writing it
to disk.

View file

@ -72,3 +72,39 @@ guides (`backend-context.md`, `frontend-context.md`) extend these.
- NEVER work directly on master/main - always create and work on a branch
Do not push anything. Make commits as you go.
## Pre-commit checklist
Before EVERY commit (no exceptions), verify each item. Treat this as
mechanical, not aspirational - a "yes" to all is required.
**Branch + scope:**
- [ ] On a feature branch (not master/main).
- [ ] This commit is one logical change. If it spans unrelated changes,
stop and split it.
- [ ] Tests for new behavior were committed BEFORE this implementation
(or this commit IS the failing-test commit).
**Code rules** (see `backend-context.md` PHP rules,
`frontend-context.md` JS rules):
- [ ] No arrow functions (`fn () =>`).
- [ ] No inline FQCNs in type hints, return types, or `::class`
references (`\App\Foo\Bar` -> hoist to `use App\Foo\Bar;`).
- [ ] No default parameter values on methods/functions/constructors.
- [ ] Find/lookup repository methods return new instances, not stored
references.
- [ ] No em dashes (use hyphens).
- [ ] Variable names are explicit (no `$t`, `$n`, `$res`, etc.).
**Mechanical checks:**
- [ ] `php-cs-fixer fix --config=.php-cs-fixer.dist.php <touched dirs>`
run, output reports 0 fixes (or any fixes are committed).
- [ ] `./vendor/bin/phpunit tests` is green.
**Commit metadata:**
- [ ] Subject is lowercase, imperative, 3-6 words.
- [ ] No claude/AI coauthor lines.
- [ ] Body present iff the subject alone cannot convey the change.
If any item fails, fix it before committing - do not bundle the fix
into a future commit.

View file

@ -8,7 +8,9 @@ use App\Node\NodeRepository;
use App\Node\UseCases\BulkCreateNodes;
use App\Node\UseCases\CreateNode;
use App\Node\UseCases\CreateNodeRequest;
use App\Text\Text;
use App\Text\TextRepository;
use App\User\User;
use DomainException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@ -20,14 +22,34 @@ class NodeController
private TextRepository $textRepository,
) {}
public function getNodesOfText(Response $response, int $textId): Response
{
public function getNodesOfText(
Request $request,
Response $response,
int $textId,
): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
$text = $this->textRepository->find($textId);
if ($text === null) {
return $response->withStatus(404);
}
if (!$this->userMayAccessText($user, $text)) {
return $this->errorResponse(
$response,
403,
'forbidden'
);
}
$nodes = $this->nodeRepository->findByTextId($textId);
$data = array_map(function ($node) {
@ -47,12 +69,32 @@ class NodeController
Response $response,
CreateNode $createNodeUseCase,
): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
$data = json_decode((string) $request->getBody(), true) ?? [];
$textId = isset($data['textId']) ? (int) $data['textId'] : null;
$title = $data['title'] ?? null;
$parentNodeId = isset($data['parentNodeId']) ? (int) $data['parentNodeId'] : null;
if ($textId !== null) {
$ownershipResponse = $this->checkTextOwnership(
$user,
$textId,
$response,
);
if ($ownershipResponse !== null) {
return $ownershipResponse;
}
}
try {
$node = $createNodeUseCase->execute(new CreateNodeRequest(
textId: $textId,
@ -80,6 +122,15 @@ class NodeController
Response $response,
BulkCreateNodes $bulkCreateNodesUseCase,
): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
$data = json_decode((string) $request->getBody(), true) ?? [];
$textId = isset($data['textId']) ? (int) $data['textId'] : null;
@ -87,6 +138,17 @@ class NodeController
$titlePrefix = isset($data['titlePrefix']) ? (string) $data['titlePrefix'] : null;
$count = isset($data['count']) ? (int) $data['count'] : null;
if ($textId !== null) {
$ownershipResponse = $this->checkTextOwnership(
$user,
$textId,
$response,
);
if ($ownershipResponse !== null) {
return $ownershipResponse;
}
}
try {
$nodes = $bulkCreateNodesUseCase->execute(new BulkCreateNodesRequest(
textId: $textId,
@ -113,4 +175,44 @@ class NodeController
$response->getBody()->write(json_encode(array_values($result)));
return $response->withStatus(201)->withHeader('Content-Type', 'application/json');
}
private function checkTextOwnership(
User $user,
int $textId,
Response $response,
): ?Response {
$text = $this->textRepository->find($textId);
if ($text === null) {
return null;
}
if (!$this->userMayAccessText($user, $text)) {
return $this->errorResponse(
$response,
403,
'forbidden'
);
}
return null;
}
private function userMayAccessText(User $user, Text $text): bool
{
if ($user->isAdmin()) {
return true;
}
return $text->getUser()->getId() === $user->getId();
}
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');
}
}

View file

@ -2,9 +2,12 @@
namespace App\Text;
use App\User\User;
class CreateTextDto
{
public function __construct(
public string $name,
public User $user,
) {}
}

View file

@ -5,13 +5,17 @@ namespace App\Text;
use App\Text\Text;
use App\Text\CreateTextDto;
use App\Text\TextRepository;
use App\User\User;
use App\User\UserRepository;
use DomainException;
class JsonTextRepository implements TextRepository
{
private string $filePath;
public function __construct()
{
public function __construct(
private UserRepository $userRepo,
) {
$this->filePath = __DIR__ . '/../../data/texts.json';
}
@ -20,8 +24,16 @@ class JsonTextRepository implements TextRepository
$texts = $this->readTexts();
$id = $this->getNextId($texts);
$text = new Text(id: $id, name: $dto->name);
$texts[] = ['id' => $id, 'name' => $dto->name];
$text = new Text(
id: $id,
name: $dto->name,
user: $dto->user,
);
$texts[] = [
'id' => $id,
'name' => $dto->name,
'userId' => $dto->user->getId(),
];
$this->writeTexts($texts);
@ -34,10 +46,7 @@ class JsonTextRepository implements TextRepository
foreach ($texts as $data) {
if ($data['id'] === $id) {
return new Text(
id: $data['id'],
name: $data['name'],
);
return $this->hydrate($data);
}
}
@ -53,15 +62,50 @@ class JsonTextRepository implements TextRepository
return array_map(
function (array $data) {
return new Text(
id: $data['id'],
name: $data['name'],
);
return $this->hydrate($data);
},
$texts
);
}
/**
* @return Text[]
*/
public function findByUser(User $user): array
{
$texts = $this->readTexts();
$userId = $user->getId();
$owned = array_filter(
$texts,
function (array $data) use ($userId) {
return $data['userId'] === $userId;
}
);
return array_map(
function (array $data) {
return $this->hydrate($data);
},
array_values($owned)
);
}
private function hydrate(array $data): Text
{
$user = $this->userRepo->find($data['userId']);
if ($user === null) {
throw new DomainException(
"User with id: {$data['userId']} doesnt exist"
);
}
return new Text(
id: $data['id'],
name: $data['name'],
user: $user,
);
}
/**
* @return array
*/

View file

@ -2,11 +2,14 @@
namespace App\Text;
use App\User\User;
class Text
{
public function __construct(
private int $id,
private string $name,
private User $user,
) {}
public function getId(): int
@ -18,4 +21,9 @@ class Text
{
return $this->name;
}
public function getUser(): User
{
return $this->user;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Text;
use App\User\User;
use App\Exceptions\BadRequestException;
use App\Text\TextRepository;
use App\Text\UseCases\CreateText;
@ -15,7 +16,7 @@ class TextController
private TextRepository $textRepository,
) {}
public function getTexts(Response $response): Response
public function getAllTexts(Response $response): Response
{
$texts = $this->textRepository->getAll();
@ -30,14 +31,63 @@ class TextController
return $response->withHeader('Content-Type', 'application/json');
}
public function getText(Response $response, int $textId): Response
{
public function getMyTexts(
Request $request,
Response $response,
): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
$texts = $this->textRepository->findByUser($user);
$data = array_map(function ($text) {
return [
'id' => $text->getId(),
'name' => $text->getName(),
];
}, $texts);
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
}
public function getText(
Request $request,
Response $response,
int $textId,
): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
$text = $this->textRepository->find($textId);
if ($text === null) {
return $response->withStatus(404);
}
if (
$text->getUser()->getId() !== $user->getId()
&& !$user->isAdmin()
) {
return $this->errorResponse(
$response,
403,
'forbidden'
);
}
$response->getBody()->write(json_encode([
'id' => $text->getId(),
'name' => $text->getName(),
@ -52,10 +102,19 @@ class TextController
): Response {
$data = $request->getParsedBody();
$name = $data['name'] ?? null;
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
try {
$text = $createTextUseCase->execute(new CreateTextRequest(
name: $name,
user: $user,
));
} catch (BadRequestException $e) {
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
@ -68,4 +127,17 @@ class TextController
]));
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');
}
}

View file

@ -4,6 +4,7 @@ namespace App\Text;
use App\Text\Text;
use App\Text\CreateTextDto;
use App\User\User;
interface TextRepository
{
@ -15,4 +16,9 @@ interface TextRepository
* @return Text[]
*/
public function getAll(): array;
/**
* @return Text[]
*/
public function findByUser(User $user): array;
}

View file

@ -24,9 +24,13 @@ class CreateText
if ($request->name === null) {
throw new BadRequestException('name is required');
}
if ($request->user === null) {
throw new BadRequestException('user is required');
}
$text = $this->textRepo->create(new CreateTextDto(
name: $request->name,
user: $request->user,
));
$this->nodeRepo->create(new CreateNodeDto(

View file

@ -2,9 +2,12 @@
namespace App\Text\UseCases;
use App\User\User;
class CreateTextRequest
{
public function __construct(
public ?string $name,
public ?User $user,
) {}
}

View file

@ -30,6 +30,26 @@ class ViewController
return $response;
}
public function userTexts(Response $response): Response
{
$html = file_get_contents(
__DIR__ . '/../../views/templates/userTexts.php'
);
$response->getBody()->write($html);
return $response;
}
public function userText(Response $response): Response
{
$html = file_get_contents(
__DIR__ . '/../../views/templates/userText.php'
);
$response->getBody()->write($html);
return $response;
}
public function home(Response $response): Response
{
$html = file_get_contents(__DIR__ . '/../../views/templates/home.php', true);

View file

@ -29,20 +29,28 @@ $app->post('/api/auth/register', [AuthController::class, 'register']);
$app->group('', function (RouteCollectorProxy $group) {
$group->get('/home', [ViewController::class, 'home']);
$group->get('/today', [ViewController::class, 'today']);
$group->get('/texts', [ViewController::class, 'userTexts']);
$group->get('/texts/{textId}', [ViewController::class, 'userText']);
$group->post('/api/auth/logout', [AuthController::class, 'logout']);
$group->get('/api/auth/me', [AuthController::class, 'me']);
$group->get('/api/texts', [TextController::class, 'getTexts']);
$group->get('/api/texts', [TextController::class, 'getMyTexts']);
$group->get(
'/api/texts/{textId}',
[TextController::class, 'getText']
);
$group->post('/api/texts', [TextController::class, 'createText']);
$group->get(
'/api/nodes/{textId}',
[NodeController::class, 'getNodesOfText']
);
$group->post('/api/nodes', [NodeController::class, 'createNode']);
$group->post(
'/api/nodes/bulk',
[NodeController::class, 'bulkCreateNodes']
);
$group->post('/api/plans', [PlanController::class, 'createPlan']);
@ -61,12 +69,7 @@ $app->group('', function (RouteCollectorProxy $group) {
[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']);
$group->get('/api/admin/texts', [TextController::class, 'getAllTexts']);
})->add(AdminMiddleware::class)->add(AuthMiddleware::class);
return $app;

View file

@ -0,0 +1,58 @@
describe('The user text detail page', () => {
beforeEach(() => {
cy.exec('npm run db:seed')
})
afterEach(() => {
cy.exec('npm run db:wipe')
})
it('renders own text with heading', () => {
cy.loginAsUser()
cy.intercept('GET', '/api/texts/0').as('getText')
cy.visit('/texts/0')
cy.wait('@getText')
cy.get('h1').should('contain', 'Tanach')
})
it('returns 403 when accessing another user text', () => {
cy.loginAsSecondUser()
cy.request({
url: '/api/texts/0',
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(403)
})
})
it('owner can add a child node', () => {
cy.loginAsUser()
cy.intercept('GET', '/api/nodes/0').as('getNodes')
cy.visit('/texts/0')
cy.wait('@getNodes')
cy.get('#text-detail > ul > li').first()
.children('button.add-child').click()
cy.get('#text-detail > ul > li').first()
.children('input.child-title').type('My new child')
cy.get('#text-detail > ul > li').first()
.children('button.save-child').click()
cy.contains('My new child')
})
it('non-owner gets 403 when posting a node to that text', () => {
cy.loginAsSecondUser()
cy.request({
method: 'POST',
url: '/api/nodes',
body: {
textId: 0,
title: 'Hijack',
parentNodeId: 0,
},
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(403)
})
})
})

View file

@ -0,0 +1,53 @@
describe('The user texts page', () => {
beforeEach(() => {
cy.exec('npm run db:seed')
cy.loginAsUser()
})
afterEach(() => {
cy.exec('npm run db:wipe')
})
it('shows my texts page with heading and form', () => {
cy.visit('/texts')
cy.get('h1').should('contain', 'My Texts')
cy.get('#newTextName').should('exist')
cy.get('#submit').should('exist')
})
it('lists the seeded text owned by the user', () => {
cy.intercept('GET', '/api/texts').as('getTexts')
cy.visit('/texts')
cy.wait('@getTexts')
cy.get('#texts-list').should('contain', 'Tanach')
})
it('creates a new text', () => {
cy.visit('/texts')
cy.get('#newTextName').type('My Notes')
cy.get('#submit').click()
cy.contains('My Notes')
})
it('newly created text links to /texts/{id}', () => {
cy.visit('/texts')
cy.get('#newTextName').type('Linked Text')
cy.get('#submit').click()
cy.get('a')
.contains('Linked Text')
.should('have.attr', 'href')
.and('match', /^\/texts\/\d+$/)
})
it('does not show texts owned by other users', () => {
cy.loginAsSecondUser()
cy.visit('/texts')
cy.get('#texts-list').should('not.contain', 'Tanach')
})
it('navigates to user text detail on click', () => {
cy.visit('/texts')
cy.get('a').contains('Tanach').click()
cy.url().should('match', /\/texts\/0$/)
cy.get('#back').should('have.attr', 'href', '/texts')
})
})

View file

@ -13,3 +13,7 @@ Cypress.Commands.add('loginAsAdmin', () => {
Cypress.Commands.add('loginAsUser', () => {
cy.login('user@example.com', 'password1')
})
Cypress.Commands.add('loginAsSecondUser', () => {
cy.login('user2@example.com', 'password2')
})

View file

@ -4,6 +4,7 @@ $texts = [
[
'id' => 0,
'name' => 'Tanach',
'userId' => 1,
],
];
@ -31,6 +32,7 @@ $nodes = [
// Default credentials:
// admin@example.com / admin1234 (admin)
// user@example.com / password1 (regular user)
// user2@example.com / password2 (second regular user, no texts seeded)
$users = [
[
'id' => 0,
@ -44,6 +46,12 @@ $users = [
'passwordHash' => password_hash('password1', PASSWORD_DEFAULT),
'isAdmin' => false,
],
[
'id' => 2,
'email' => 'user2@example.com',
'passwordHash' => password_hash('password2', PASSWORD_DEFAULT),
'isAdmin' => false,
],
];
$plans = [];

View file

@ -3,17 +3,18 @@ document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('texts-form');
async function loadTexts() {
const res = await fetch('/api/texts', {
const res = await fetch('/api/admin/texts', {
credentials: 'same-origin',
});
const texts = await res.json();
textsList.innerHTML = texts.map(text =>
'<li class="card"><a class="card-link"'
textsList.innerHTML = texts.map(function (text) {
return '<li class="card"><a class="card-link"'
+ ' href=/admin/texts/'
+ text.id
+ '>'
+ text.name
+ '</a></li>').join('');
+ '</a></li>';
}).join('');
}
form.addEventListener('submit', async (e) => {

43
public/js/userTexts.js Normal file
View file

@ -0,0 +1,43 @@
document.addEventListener('DOMContentLoaded', () => {
const textsList = document.getElementById('texts-list');
const form = document.getElementById('texts-form');
async function loadTexts() {
const response = await fetch('/api/texts', {
credentials: 'same-origin',
});
const texts = await response.json();
textsList.innerHTML = texts.map(function (text) {
return '<li class="card"><a class="card-link"'
+ ' href=/texts/'
+ text.id
+ '>'
+ text.name
+ '</a></li>';
}).join('');
}
form.addEventListener('submit', async (submitEvent) => {
submitEvent.preventDefault();
const formData = new FormData(form);
const response = await fetch('/api/texts', {
method: 'POST',
credentials: 'same-origin',
body: formData,
});
if (response.ok) {
const text = await response.json();
const li = document.createElement('li');
li.className = 'card';
const link = document.createElement('a');
link.className = 'card-link';
link.href = '/texts/' + text.id;
link.textContent = text.name;
li.appendChild(link);
textsList.appendChild(li);
form.reset();
}
});
loadTexts();
});

View file

@ -5,6 +5,7 @@ namespace Tests\Fakes;
use App\Text\CreateTextDto;
use App\Text\Text;
use App\Text\TextRepository;
use App\User\User;
class FakeTextRepository implements TextRepository
{
@ -19,6 +20,7 @@ class FakeTextRepository implements TextRepository
$text = new Text(
id: $id,
name: $dto->name,
user: $dto->user,
);
$this->existingTexts[$id] = $text;
@ -27,19 +29,15 @@ class FakeTextRepository implements TextRepository
public function find(int $id): ?Text
{
$text = array_find(
$this->existingTexts,
function (Text $text) use ($id) {
return $text->getId() === $id;
}
);
if ($text === null) {
if (!isset($this->existingTexts[$id])) {
return null;
}
$text = $this->existingTexts[$id];
return new Text(
id: $id,
id: $text->getId(),
name: $text->getName(),
user: $text->getUser(),
);
}
@ -58,9 +56,35 @@ class FakeTextRepository implements TextRepository
return new Text(
id: $text->getId(),
name: $text->getName(),
user: $text->getUser(),
);
},
$this->existingTexts
array_values($this->existingTexts)
);
}
/**
* @return Text[]
*/
public function findByUser(User $user): array
{
$userId = $user->getId();
$owned = array_filter(
$this->existingTexts,
function (Text $text) use ($userId) {
return $text->getUser()->getId() === $userId;
}
);
return array_map(
function (Text $text) {
return new Text(
id: $text->getId(),
name: $text->getName(),
user: $text->getUser(),
);
},
array_values($owned)
);
}
}

View file

@ -8,10 +8,13 @@ use App\Node\Node;
use App\Node\UseCases\BulkCreateNodes;
use App\Node\UseCases\BulkCreateNodesRequest;
use App\Text\CreateTextDto;
use App\User\UseCases\CreateUserDto;
use App\ValueObjects\EmailAddress;
use DomainException;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
use Tests\Fakes\FakeUserRepository;
class BulkCreateNodesTest extends TestCase
{
@ -22,8 +25,14 @@ class BulkCreateNodesTest extends TestCase
public function setUp(): void
{
$userRepo = new FakeUserRepository();
$user = $userRepo->create(new CreateUserDto(
email: new EmailAddress('a@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->textRepo = new FakeTextRepository();
$this->textRepo->create(new CreateTextDto(name: 'text'));
$this->textRepo->create(new CreateTextDto(name: 'text', user: $user));
$this->nodeRepo = new FakeNodeRepository();
$text = $this->textRepo->find(0);

View file

@ -9,10 +9,13 @@ use App\Node\UseCases\CreateNode;
use App\Node\UseCases\CreateNodeRequest;
use App\Text\CreateTextDto;
use App\Text\Text;
use App\User\UseCases\CreateUserDto;
use App\ValueObjects\EmailAddress;
use DomainException;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
use Tests\Fakes\FakeUserRepository;
class CreateNodeTest extends TestCase
{
@ -22,9 +25,16 @@ class CreateNodeTest extends TestCase
public function setUp(): void
{
$userRepo = new FakeUserRepository();
$user = $userRepo->create(new CreateUserDto(
email: new EmailAddress('a@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->textRepo = new FakeTextRepository();
$this->textRepo->create(new CreateTextDto(
name: 'text'
name: 'text',
user: $user,
));
$this->nodeRepo = new FakeNodeRepository();
$this->useCase = new CreateNode(

View file

@ -37,7 +37,7 @@ class CreatePlanTest extends TestCase
$this->textRepo = new FakeTextRepository();
$this->nodeRepo = new FakeNodeRepository();
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->userRepo->create(new CreateUserDto(
$user = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('test@test.com'),
passwordHash: '',
isAdmin: false,
@ -47,7 +47,10 @@ class CreatePlanTest extends TestCase
planRepo: $this->planRepo,
nodeRepo: $this->nodeRepo,
);
$this->textRepo->create(new CreateTextDto('testname'));
$this->textRepo->create(new CreateTextDto(
name: 'testname',
user: $user,
));
$this->useCase = new CreatePlan(
$this->planRepo,
$this->userRepo,

View file

@ -35,19 +35,20 @@ class CreateScheduledNodeTest extends TestCase
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->planRepo = new FakePlanRepository();
$this->nodeRepo = new FakeNodeRepository();
$user = new User(
id: 0,
email: new EmailAddress('test@test.com'),
passwordHash: 'hashed:password1',
isAdmin: false,
);
$this->nodeRepo->create(new CreateNodeDto(
text: new Text(0, 'text name'),
text: new Text(0, 'text name', $user),
title: 'test node',
parentNode: null,
));
$this->planRepo->create(new CreatePlanDto(
name: 'testplan',
user: new User(
id: 0,
email: new EmailAddress('test@test.com'),
passwordHash: 'hashed:password1',
isAdmin: false,
),
user: $user,
));
$this->useCase = new CreateScheduledNode(
$this->scheduledNodeRepo,

View file

@ -11,6 +11,7 @@ use App\ScheduledNode\UseCases\GetTodaysSchedule;
use App\ScheduledNode\UseCases\GetTodaysScheduleRequest;
use App\Text\Text;
use App\User\UseCases\CreateUserDto;
use App\User\User;
use App\ValueObjects\EmailAddress;
use DateTimeImmutable;
use DomainException;
@ -29,19 +30,21 @@ class GetTodaysScheduleTest extends TestCase
private GetTodaysSchedule $useCase;
private User $user;
protected function setUp(): void
{
$this->userRepo = new FakeUserRepository();
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->planRepo = new FakePlanRepository();
$user = $this->userRepo->create(new CreateUserDto(
$this->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,
user: $this->user,
));
$this->scheduledNodeRepo->create(new CreateScheduledNodeDto(
date: new DateTimeImmutable('2025-01-02'),
@ -49,7 +52,7 @@ class GetTodaysScheduleTest extends TestCase
node: new Node(
id: 0,
title: 'test node',
text: new Text(id: 0, name: 'test text'),
text: new Text(id: 0, name: 'test text', user: $this->user),
parentNode: null,
),
));
@ -79,7 +82,7 @@ class GetTodaysScheduleTest extends TestCase
node: new Node(
id: 0,
title: 'test node',
text: new Text(id: 0, name: 'test text'),
text: new Text(id: 0, name: 'test text', user: $this->user),
parentNode: null,
),
));
@ -99,7 +102,7 @@ class GetTodaysScheduleTest extends TestCase
node: new Node(
id: 0,
title: 'test node',
text: new Text(id: 0, name: 'test text'),
text: new Text(id: 0, name: 'test text', user: $this->user),
parentNode: null,
),
)
@ -172,7 +175,7 @@ class GetTodaysScheduleTest extends TestCase
node: new Node(
id: 0,
title: 'future node',
text: new Text(id: 0, name: 'test text'),
text: new Text(id: 0, name: 'test text', user: $this->user),
parentNode: null,
),
));
@ -202,7 +205,7 @@ class GetTodaysScheduleTest extends TestCase
node: new Node(
id: 0,
title: 'other node',
text: new Text(id: 0, name: 'test text'),
text: new Text(id: 0, name: 'test text', user: $otherUser),
parentNode: null,
),
));

View file

@ -7,22 +7,36 @@ use App\Text\Text;
use App\Text\TextRepository;
use App\Text\UseCases\CreateText;
use App\Text\UseCases\CreateTextRequest;
use App\User\UseCases\CreateUserDto;
use App\User\User;
use App\ValueObjects\EmailAddress;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
use Tests\Fakes\FakeUserRepository;
class CreateTextTest extends TestCase
{
private FakeTextRepository $textRepo;
private FakeUserRepository $userRepo;
private FakeNodeRepository $nodeRepo;
private CreateText $useCase;
private User $user;
protected function setUp(): void
{
$this->textRepo = new FakeTextRepository();
$this->nodeRepo = new FakeNodeRepository();
$this->userRepo = new FakeUserRepository();
$this->user = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('a@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->useCase = new CreateText(
$this->textRepo,
$this->nodeRepo,
@ -33,6 +47,7 @@ class CreateTextTest extends TestCase
{
$text = $this->useCase->execute(new CreateTextRequest(
name: 'test',
user: $this->user,
));
$this->assertInstanceOf(TextRepository::class, $this->textRepo);
$this->assertInstanceOf(Text::class, $text);
@ -43,6 +58,7 @@ class CreateTextTest extends TestCase
{
$text = $this->useCase->execute(new CreateTextRequest(
name: 'my text',
user: $this->user,
));
$nodes = $this->nodeRepo->findByTextId($text->getId());
@ -53,6 +69,17 @@ class CreateTextTest extends TestCase
$this->assertNull($rootNode->getParentNode());
}
public function test_text_belongs_to_user(): void
{
$text = $this->useCase->execute(new CreateTextRequest(
name: 'my text',
user: $this->user,
));
$this->assertSame($this->user, $text->getUser());
$this->assertEquals($this->user->getId(), $text->getUser()->getId());
}
public function test_throws_if_name_is_null(): void
{
$this->expectException(BadRequestException::class);
@ -60,6 +87,18 @@ class CreateTextTest extends TestCase
$this->useCase->execute(new CreateTextRequest(
name: null,
user: $this->user,
));
}
public function test_throws_if_user_is_null(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('user is required');
$this->useCase->execute(new CreateTextRequest(
name: 'name',
user: null,
));
}
}

View file

@ -6,6 +6,9 @@ use App\Node\CreateNodeDto;
use App\Node\NodeController;
use App\Node\UseCases\BulkCreateNodes;
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;
use Slim\Psr7\Factory\ServerRequestFactory;
@ -13,6 +16,7 @@ use Slim\Psr7\Factory\StreamFactory;
use Slim\Psr7\Response;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
use Tests\Fakes\FakeUserRepository;
class BulkCreateNodesControllerTest extends TestCase
{
@ -20,11 +24,33 @@ class BulkCreateNodesControllerTest extends TestCase
private FakeNodeRepository $nodeRepo;
private BulkCreateNodes $useCase;
private NodeController $controller;
private User $user;
private User $otherUser;
private User $admin;
public function setUp(): void
{
$userRepo = new FakeUserRepository();
$this->user = $userRepo->create(new CreateUserDto(
email: new EmailAddress('a@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->otherUser = $userRepo->create(new CreateUserDto(
email: new EmailAddress('other@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->admin = $userRepo->create(new CreateUserDto(
email: new EmailAddress('admin@b.com'),
passwordHash: '',
isAdmin: true,
));
$this->textRepo = new FakeTextRepository();
$text = $this->textRepo->create(new CreateTextDto(name: 'test text'));
$text = $this->textRepo->create(new CreateTextDto(
name: 'test text',
user: $this->user,
));
$this->nodeRepo = new FakeNodeRepository();
$this->nodeRepo->create(new CreateNodeDto(
@ -42,24 +68,30 @@ class BulkCreateNodesControllerTest extends TestCase
);
}
private function makeRequest(array $data): ServerRequestInterface
{
private function makeRequest(
array $data,
User $user,
): ServerRequestInterface {
$body = new StreamFactory()->createStream(json_encode($data));
return new ServerRequestFactory()
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/nodes/bulk')
->withHeader('Content-Type', 'application/json')
->withBody($body);
return $request->withAttribute('user', $user);
}
public function test_bulk_create_nodes_returns_201_with_created_nodes(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 3,
]),
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 3,
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -72,12 +104,15 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_returns_correct_count(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 10,
]),
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 10,
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -89,12 +124,15 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_returns_correct_titles(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Chapter',
'count' => 3,
]),
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Chapter',
'count' => 3,
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -108,12 +146,15 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_response_includes_id_and_parent_node_id(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 2,
]),
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 2,
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -126,11 +167,14 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_returns_400_when_title_prefix_missing(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'parentNodeId' => 0,
'count' => 5,
]),
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'count' => 5,
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -141,12 +185,15 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_returns_400_when_count_is_zero(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 0,
]),
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 0,
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -157,11 +204,14 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_returns_400_when_count_is_missing(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
]),
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -172,11 +222,14 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_returns_400_when_parent_node_id_missing(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'titlePrefix' => 'Page',
'count' => 5,
]),
$this->makeRequest(
[
'textId' => 0,
'titlePrefix' => 'Page',
'count' => 5,
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -187,12 +240,15 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_returns_404_when_text_not_found(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 99,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 5,
]),
$this->makeRequest(
[
'textId' => 99,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 5,
],
$this->user,
),
new Response(),
$this->useCase,
);
@ -203,16 +259,57 @@ class BulkCreateNodesControllerTest extends TestCase
public function test_bulk_create_nodes_returns_404_when_parent_node_not_found(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest([
'textId' => 0,
'parentNodeId' => 99,
'titlePrefix' => 'Page',
'count' => 5,
]),
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 99,
'titlePrefix' => 'Page',
'count' => 5,
],
$this->user,
),
new Response(),
$this->useCase,
);
$this->assertEquals(404, $response->getStatusCode());
}
public function test_bulk_create_nodes_returns_403_when_not_owner(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 3,
],
$this->otherUser,
),
new Response(),
$this->useCase,
);
$this->assertEquals(403, $response->getStatusCode());
}
public function test_bulk_create_nodes_allows_admin_on_any_text(): void
{
$response = $this->controller->bulkCreateNodes(
$this->makeRequest(
[
'textId' => 0,
'parentNodeId' => 0,
'titlePrefix' => 'Page',
'count' => 2,
],
$this->admin,
),
new Response(),
$this->useCase,
);
$this->assertEquals(201, $response->getStatusCode());
}
}

View file

@ -6,27 +6,82 @@ use App\Node\CreateNodeDto;
use App\Node\NodeController;
use App\Node\UseCases\CreateNode;
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;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Factory\StreamFactory;
use Slim\Psr7\Response;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
use Tests\Fakes\FakeUserRepository;
class NodeControllerTest extends TestCase
{
private FakeTextRepository $textRepo;
private FakeNodeRepository $nodeRepo;
private NodeController $controller;
private User $user;
private User $otherUser;
private User $admin;
public function setUp(): void
{
$userRepo = new FakeUserRepository();
$this->user = $userRepo->create(new CreateUserDto(
email: new EmailAddress('a@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->otherUser = $userRepo->create(new CreateUserDto(
email: new EmailAddress('other@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->admin = $userRepo->create(new CreateUserDto(
email: new EmailAddress('admin@b.com'),
passwordHash: '',
isAdmin: true,
));
$this->textRepo = new FakeTextRepository();
$this->textRepo->create(new CreateTextDto(name: 'test text'));
$this->textRepo->create(new CreateTextDto(
name: 'test text',
user: $this->user,
));
$this->nodeRepo = new FakeNodeRepository();
$this->controller = new NodeController($this->nodeRepo, $this->textRepo);
$this->controller = new NodeController(
$this->nodeRepo,
$this->textRepo,
);
}
private function makeRequest(
array $body,
?User $user,
): ServerRequestInterface {
$stream = new StreamFactory()->createStream(json_encode($body));
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/nodes')
->withHeader('Content-Type', 'application/json')
->withBody($stream);
if ($user !== null) {
$request = $request->withAttribute('user', $user);
}
return $request;
}
private function makeGetRequest(?User $user): ServerRequestInterface
{
$request = new ServerRequestFactory()
->createServerRequest('GET', 'http://localhost/api/nodes/0');
if ($user !== null) {
$request = $request->withAttribute('user', $user);
}
return $request;
}
public function test_get_nodes_of_text_returns_flat_array(): void
@ -38,7 +93,11 @@ class NodeControllerTest extends TestCase
parentNode: null,
));
$response = $this->controller->getNodesOfText(new Response(), 0);
$response = $this->controller->getNodesOfText(
$this->makeGetRequest($this->user),
new Response(),
0,
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(
@ -51,7 +110,11 @@ class NodeControllerTest extends TestCase
public function test_get_nodes_of_text_returns_empty_array_when_no_nodes(): void
{
$response = $this->controller->getNodesOfText(new Response(), 0);
$response = $this->controller->getNodesOfText(
$this->makeGetRequest($this->user),
new Response(),
0,
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(json_encode([]), $response->getBody());
@ -59,11 +122,44 @@ class NodeControllerTest extends TestCase
public function test_get_nodes_of_text_returns_404_for_unknown_text(): void
{
$response = $this->controller->getNodesOfText(new Response(), 99);
$response = $this->controller->getNodesOfText(
$this->makeGetRequest($this->user),
new Response(),
99,
);
$this->assertEquals(404, $response->getStatusCode());
}
public function test_get_nodes_of_text_returns_403_when_not_owner(): void
{
$response = $this->controller->getNodesOfText(
$this->makeGetRequest($this->otherUser),
new Response(),
0,
);
$this->assertEquals(403, $response->getStatusCode());
}
public function test_get_nodes_of_text_allows_admin(): void
{
$text = $this->textRepo->find(0);
$this->nodeRepo->create(new CreateNodeDto(
text: $text,
title: 'Root Node',
parentNode: null,
));
$response = $this->controller->getNodesOfText(
$this->makeGetRequest($this->admin),
new Response(),
0,
);
$this->assertEquals(200, $response->getStatusCode());
}
public function test_get_nodes_includes_parent_node_id(): void
{
$text = $this->textRepo->find(0);
@ -78,7 +174,11 @@ class NodeControllerTest extends TestCase
parentNode: $rootNode,
));
$response = $this->controller->getNodesOfText(new Response(), 0);
$response = $this->controller->getNodesOfText(
$this->makeGetRequest($this->user),
new Response(),
0,
);
$body = json_decode($response->getBody(), true);
$this->assertEquals(0, $body[1]['parentNodeId']);
@ -93,15 +193,14 @@ class NodeControllerTest extends TestCase
parentNode: null,
));
$body = new StreamFactory()->createStream(json_encode([
'textId' => 0,
'title' => 'Child Node',
'parentNodeId' => $rootNode->getId(),
]));
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/nodes')
->withHeader('Content-Type', 'application/json')
->withBody($body);
$request = $this->makeRequest(
[
'textId' => 0,
'title' => 'Child Node',
'parentNodeId' => $rootNode->getId(),
],
$this->user,
);
$response = $this->controller->createNode(
$request,
@ -118,14 +217,13 @@ class NodeControllerTest extends TestCase
public function test_create_node_returns_400_when_title_missing(): void
{
$body = new StreamFactory()->createStream(json_encode([
'textId' => 0,
'parentNodeId' => null,
]));
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/nodes')
->withHeader('Content-Type', 'application/json')
->withBody($body);
$request = $this->makeRequest(
[
'textId' => 0,
'parentNodeId' => null,
],
$this->user,
);
$response = $this->controller->createNode(
$request,
@ -138,15 +236,14 @@ class NodeControllerTest extends TestCase
public function test_create_node_returns_404_when_text_not_found(): void
{
$body = new StreamFactory()->createStream(json_encode([
'textId' => 99,
'title' => 'Some Node',
'parentNodeId' => null,
]));
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/nodes')
->withHeader('Content-Type', 'application/json')
->withBody($body);
$request = $this->makeRequest(
[
'textId' => 99,
'title' => 'Some Node',
'parentNodeId' => null,
],
$this->user,
);
$response = $this->controller->createNode(
$request,
@ -156,4 +253,44 @@ class NodeControllerTest extends TestCase
$this->assertEquals(404, $response->getStatusCode());
}
public function test_create_node_returns_403_when_not_owner(): void
{
$request = $this->makeRequest(
[
'textId' => 0,
'title' => 'Hijack',
'parentNodeId' => null,
],
$this->otherUser,
);
$response = $this->controller->createNode(
$request,
new Response(),
new CreateNode($this->nodeRepo, $this->textRepo),
);
$this->assertEquals(403, $response->getStatusCode());
}
public function test_create_node_allows_admin_on_any_text(): void
{
$request = $this->makeRequest(
[
'textId' => 0,
'title' => 'Admin Root',
'parentNodeId' => null,
],
$this->admin,
);
$response = $this->controller->createNode(
$request,
new Response(),
new CreateNode($this->nodeRepo, $this->textRepo),
);
$this->assertEquals(201, $response->getStatusCode());
}
}

View file

@ -45,7 +45,10 @@ class PlanControllerTest extends TestCase
passwordHash: '',
isAdmin: false,
));
$text = $this->textRepo->create(new CreateTextDto('testname'));
$text = $this->textRepo->create(new CreateTextDto(
name: 'testname',
user: $this->user,
));
$this->nodeRepo->create(new CreateNodeDto(
text: $text,
title: 'Root Node',

View file

@ -82,7 +82,7 @@ class ScheduledNodeControllerTest extends TestCase
node: new Node(
id: 0,
title: $nodeTitle,
text: new Text(id: 0, name: 'test text'),
text: new Text(id: 0, name: 'test text', user: $user),
parentNode: null,
),
));

View file

@ -5,30 +5,71 @@ namespace Tests\e2e\Controllers;
use App\Text\CreateTextDto;
use App\Text\TextController;
use App\Text\UseCases\CreateText;
use App\User\UseCases\CreateUserDto;
use App\User\User;
use App\ValueObjects\EmailAddress;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
use Tests\Fakes\FakeUserRepository;
class TextControllerTest extends TestCase
{
private FakeTextRepository $textRepo;
private FakeUserRepository $userRepo;
private TextController $controller;
private User $user;
private User $otherUser;
private User $admin;
public function setUp(): void
{
$this->textRepo = new FakeTextRepository();
$this->userRepo = new FakeUserRepository();
$this->user = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('a@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->otherUser = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('other@b.com'),
passwordHash: '',
isAdmin: false,
));
$this->admin = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('admin@b.com'),
passwordHash: '',
isAdmin: true,
));
$this->textRepo->create(new CreateTextDto(
name: 'test text',
user: $this->user,
));
$this->controller = new TextController($this->textRepo);
}
private function makeRequest(?User $user): ServerRequestInterface
{
$request = new ServerRequestFactory()
->createServerRequest('GET', 'http://localhost/texts');
if ($user !== null) {
$request = $request->withAttribute('user', $user);
}
return $request;
}
public function test_get_one_text(): void
{
$response = $this->controller->getText(
$this->makeRequest($this->user),
new Response(),
0,
);
@ -41,12 +82,50 @@ class TextControllerTest extends TestCase
);
}
public function test_get_all_texts(): void
public function test_get_text_returns_404_when_not_found(): void
{
$response = $this->controller->getText(
$this->makeRequest($this->user),
new Response(),
99,
);
$this->assertEquals(404, $response->getStatusCode());
}
public function test_get_text_returns_403_when_not_owner(): void
{
$response = $this->controller->getText(
$this->makeRequest($this->otherUser),
new Response(),
0,
);
$this->assertEquals(403, $response->getStatusCode());
}
public function test_get_text_allows_admin_to_read_any_text(): void
{
$response = $this->controller->getText(
$this->makeRequest($this->admin),
new Response(),
0,
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(
json_encode([
'id' => 0,
'name' => 'test text',
]),
$response->getBody()
);
}
public function test_get_all_texts_returns_every_text(): void
{
$this->textRepo->create(new CreateTextDto(
name: 'test text 2',
name: 'other users text',
user: $this->otherUser,
));
$response = $this->controller->getTexts(new Response());
$response = $this->controller->getAllTexts(new Response());
$this->assertEquals(
json_encode([
[
@ -55,18 +134,57 @@ class TextControllerTest extends TestCase
],
[
'id' => 1,
'name' => 'test text 2',
'name' => 'other users text',
],
]),
$response->getBody()
);
}
public function test_get_my_texts_returns_only_own_texts(): void
{
$this->textRepo->create(new CreateTextDto(
name: 'other users text',
user: $this->otherUser,
));
$this->textRepo->create(new CreateTextDto(
name: 'second of mine',
user: $this->user,
));
$response = $this->controller->getMyTexts(
$this->makeRequest($this->user),
new Response(),
);
$this->assertEquals(
json_encode([
[
'id' => 0,
'name' => 'test text',
],
[
'id' => 2,
'name' => 'second of mine',
],
]),
$response->getBody()
);
}
public function test_get_my_texts_returns_empty_when_user_has_none(): void
{
$response = $this->controller->getMyTexts(
$this->makeRequest($this->otherUser),
new Response(),
);
$this->assertEquals(json_encode([]), $response->getBody());
}
public function test_create_text(): void
{
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/texts')
->withParsedBody(['name' => 'my new text']);
->withParsedBody(['name' => 'my new text'])
->withAttribute('user', $this->user);
$response = $this->controller->createText(
$request,
@ -89,7 +207,8 @@ class TextControllerTest extends TestCase
{
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/texts')
->withParsedBody([]);
->withParsedBody([])
->withAttribute('user', $this->user);
$response = $this->controller->createText(
$request,
@ -104,4 +223,74 @@ class TextControllerTest extends TestCase
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_text_persists_user_from_session(): void
{
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/texts')
->withParsedBody(['name' => 'my new text'])
->withAttribute('user', $this->user);
$this->controller->createText(
$request,
new Response(),
new CreateText(
$this->textRepo,
new FakeNodeRepository(),
),
);
$stored = $this->textRepo->find(1);
$this->assertNotNull($stored);
$this->assertEquals(
$this->user->getId(),
$stored->getUser()->getId()
);
}
public function test_create_text_ignores_user_id_in_body(): void
{
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/texts')
->withParsedBody([
'name' => 'my new text',
'userId' => $this->otherUser->getId(),
])
->withAttribute('user', $this->user);
$this->controller->createText(
$request,
new Response(),
new CreateText(
$this->textRepo,
new FakeNodeRepository(),
),
);
$stored = $this->textRepo->find(1);
$this->assertEquals(
$this->user->getId(),
$stored->getUser()->getId()
);
}
public function test_create_text_returns_401_when_unauthenticated(): void
{
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/texts')
->withParsedBody(['name' => 'my new text']);
$response = $this->controller->createText(
$request,
new Response(),
new CreateText(
$this->textRepo,
new FakeNodeRepository(),
),
);
$this->assertEquals(401, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
}

View file

@ -11,6 +11,9 @@
<div class="site-header-inner">
<h1>Home</h1>
<div class="cluster">
<a class="btn btn-secondary" href="/texts" id="manage-texts">
My texts
</a>
<a class="btn btn-secondary" href="/today">
Today's schedule
</a>

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Daily Goals - Text</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header class="site-header">
<div class="site-header-inner">
<a class="btn btn-secondary" href="/texts" id="back">
Back to My Texts
</a>
</div>
</header>
<main class="container container-wide stack">
<div id="text-detail" class="node-tree stack"></div>
</main>
<script src="/js/text.js"></script>
</body>
</html>

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Daily Goals - My Texts</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header class="site-header">
<div class="site-header-inner">
<h1>My Texts</h1>
<div class="cluster">
<a class="btn btn-secondary" href="/home" id="back">
Back to Home
</a>
<button id="logout" class="btn btn-danger">Logout</button>
</div>
</div>
</header>
<main class="container stack-lg">
<ul id="texts-list" class="list-cards"></ul>
<form id="texts-form" action="/api/texts" method="POST"
class="card stack">
<label>New text name
<input id="newTextName" name="name" type="text" />
</label>
<div class="cluster cluster-end">
<button id="submit" class="btn btn-primary" type="submit">
Add text
</button>
</div>
</form>
</main>
<script src="/js/auth.js"></script>
<script src="/js/userTexts.js"></script>
</body>
</html>