Merge branch 'text-has-user'
This commit is contained in:
commit
3b29c7b90f
37 changed files with 1270 additions and 167 deletions
18
AGENTS.md
18
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- Reevaluate validation in node controller what needs to be moved into use cases.
|
||||
- checkTextOwnership is definitely business logic
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
36
ai/shared.md
36
ai/shared.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
namespace App\Text;
|
||||
|
||||
use App\User\User;
|
||||
|
||||
class CreateTextDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public User $user,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +62,47 @@ class JsonTextRepository implements TextRepository
|
|||
|
||||
return array_map(
|
||||
function (array $data) {
|
||||
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'],
|
||||
);
|
||||
},
|
||||
$texts
|
||||
user: $user,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
namespace App\Text\UseCases;
|
||||
|
||||
use App\User\User;
|
||||
|
||||
class CreateTextRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $name,
|
||||
public ?User $user,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
58
cypress/e2e/userText.cy.js
Normal file
58
cypress/e2e/userText.cy.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
53
cypress/e2e/userTexts.cy.js
Normal file
53
cypress/e2e/userTexts.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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
43
public/js/userTexts.js
Normal 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();
|
||||
});
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
$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([
|
||||
$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([
|
||||
$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([
|
||||
$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([
|
||||
$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([
|
||||
$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([
|
||||
$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([
|
||||
$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([
|
||||
$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([
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
$request = $this->makeRequest(
|
||||
[
|
||||
'textId' => 0,
|
||||
'title' => 'Child Node',
|
||||
'parentNodeId' => $rootNode->getId(),
|
||||
]));
|
||||
$request = new ServerRequestFactory()
|
||||
->createServerRequest('POST', 'http://localhost/api/nodes')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($body);
|
||||
],
|
||||
$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([
|
||||
$request = $this->makeRequest(
|
||||
[
|
||||
'textId' => 0,
|
||||
'parentNodeId' => null,
|
||||
]));
|
||||
$request = new ServerRequestFactory()
|
||||
->createServerRequest('POST', 'http://localhost/api/nodes')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($body);
|
||||
],
|
||||
$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([
|
||||
$request = $this->makeRequest(
|
||||
[
|
||||
'textId' => 99,
|
||||
'title' => 'Some Node',
|
||||
'parentNodeId' => null,
|
||||
]));
|
||||
$request = new ServerRequestFactory()
|
||||
->createServerRequest('POST', 'http://localhost/api/nodes')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($body);
|
||||
],
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
22
views/templates/userText.php
Normal file
22
views/templates/userText.php
Normal 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>
|
||||
38
views/templates/userTexts.php
Normal file
38
views/templates/userTexts.php
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue