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/shared.md
|
||||||
@ai/backend-context.md
|
@ai/backend-context.md
|
||||||
@ai/frontend-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">
|
<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" />
|
<mxGeometry height="80" width="80" x="450" y="290" as="geometry" />
|
||||||
</mxCell>
|
</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" />
|
<mxGeometry relative="1" as="geometry" />
|
||||||
</mxCell>
|
</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" />
|
<mxGeometry relative="1" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="UlVOh7WOaItsqOB8hf6W-5" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Scheduled Node" vertex="1">
|
<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>
|
||||||
<mxCell id="UlVOh7WOaItsqOB8hf6W-8" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Text" vertex="1">
|
<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>
|
||||||
<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">
|
<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" />
|
<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="">
|
<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">
|
<mxGeometry relative="1" as="geometry">
|
||||||
<Array as="points">
|
<Array as="points">
|
||||||
<mxPoint x="800" y="110" />
|
<mxPoint x="640" y="30" />
|
||||||
<mxPoint x="800" y="150" />
|
<mxPoint x="640" y="70" />
|
||||||
</Array>
|
</Array>
|
||||||
<mxPoint x="800" y="150" as="targetPoint" />
|
<mxPoint x="640" y="70" as="targetPoint" />
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="UlVOh7WOaItsqOB8hf6W-9" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Node" vertex="1">
|
<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>
|
||||||
<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">
|
<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" />
|
<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
|
# TODO
|
||||||
- Test Email Address Value Object
|
- 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
|
Run `php-cs-fixer fix` on worked-on directories before committing (uses the
|
||||||
existing `.php-cs-fixer.dist.php` config).
|
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 -
|
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
|
commit them together as a single logical unit, per the "one logical change
|
||||||
per commit" rule in `shared.md`.
|
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
|
- NEVER work directly on master/main - always create and work on a branch
|
||||||
|
|
||||||
Do not push anything. Make commits as you go.
|
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\BulkCreateNodes;
|
||||||
use App\Node\UseCases\CreateNode;
|
use App\Node\UseCases\CreateNode;
|
||||||
use App\Node\UseCases\CreateNodeRequest;
|
use App\Node\UseCases\CreateNodeRequest;
|
||||||
|
use App\Text\Text;
|
||||||
use App\Text\TextRepository;
|
use App\Text\TextRepository;
|
||||||
|
use App\User\User;
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
@ -20,14 +22,34 @@ class NodeController
|
||||||
private TextRepository $textRepository,
|
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);
|
$text = $this->textRepository->find($textId);
|
||||||
|
|
||||||
if ($text === null) {
|
if ($text === null) {
|
||||||
return $response->withStatus(404);
|
return $response->withStatus(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$this->userMayAccessText($user, $text)) {
|
||||||
|
return $this->errorResponse(
|
||||||
|
$response,
|
||||||
|
403,
|
||||||
|
'forbidden'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$nodes = $this->nodeRepository->findByTextId($textId);
|
$nodes = $this->nodeRepository->findByTextId($textId);
|
||||||
|
|
||||||
$data = array_map(function ($node) {
|
$data = array_map(function ($node) {
|
||||||
|
|
@ -47,12 +69,32 @@ class NodeController
|
||||||
Response $response,
|
Response $response,
|
||||||
CreateNode $createNodeUseCase,
|
CreateNode $createNodeUseCase,
|
||||||
): Response {
|
): Response {
|
||||||
|
$user = $request->getAttribute('user');
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return $this->errorResponse(
|
||||||
|
$response,
|
||||||
|
401,
|
||||||
|
'unauthenticated'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$data = json_decode((string) $request->getBody(), true) ?? [];
|
$data = json_decode((string) $request->getBody(), true) ?? [];
|
||||||
|
|
||||||
$textId = isset($data['textId']) ? (int) $data['textId'] : null;
|
$textId = isset($data['textId']) ? (int) $data['textId'] : null;
|
||||||
$title = $data['title'] ?? null;
|
$title = $data['title'] ?? null;
|
||||||
$parentNodeId = isset($data['parentNodeId']) ? (int) $data['parentNodeId'] : null;
|
$parentNodeId = isset($data['parentNodeId']) ? (int) $data['parentNodeId'] : null;
|
||||||
|
|
||||||
|
if ($textId !== null) {
|
||||||
|
$ownershipResponse = $this->checkTextOwnership(
|
||||||
|
$user,
|
||||||
|
$textId,
|
||||||
|
$response,
|
||||||
|
);
|
||||||
|
if ($ownershipResponse !== null) {
|
||||||
|
return $ownershipResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$node = $createNodeUseCase->execute(new CreateNodeRequest(
|
$node = $createNodeUseCase->execute(new CreateNodeRequest(
|
||||||
textId: $textId,
|
textId: $textId,
|
||||||
|
|
@ -80,6 +122,15 @@ class NodeController
|
||||||
Response $response,
|
Response $response,
|
||||||
BulkCreateNodes $bulkCreateNodesUseCase,
|
BulkCreateNodes $bulkCreateNodesUseCase,
|
||||||
): Response {
|
): Response {
|
||||||
|
$user = $request->getAttribute('user');
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return $this->errorResponse(
|
||||||
|
$response,
|
||||||
|
401,
|
||||||
|
'unauthenticated'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$data = json_decode((string) $request->getBody(), true) ?? [];
|
$data = json_decode((string) $request->getBody(), true) ?? [];
|
||||||
|
|
||||||
$textId = isset($data['textId']) ? (int) $data['textId'] : null;
|
$textId = isset($data['textId']) ? (int) $data['textId'] : null;
|
||||||
|
|
@ -87,6 +138,17 @@ class NodeController
|
||||||
$titlePrefix = isset($data['titlePrefix']) ? (string) $data['titlePrefix'] : null;
|
$titlePrefix = isset($data['titlePrefix']) ? (string) $data['titlePrefix'] : null;
|
||||||
$count = isset($data['count']) ? (int) $data['count'] : null;
|
$count = isset($data['count']) ? (int) $data['count'] : null;
|
||||||
|
|
||||||
|
if ($textId !== null) {
|
||||||
|
$ownershipResponse = $this->checkTextOwnership(
|
||||||
|
$user,
|
||||||
|
$textId,
|
||||||
|
$response,
|
||||||
|
);
|
||||||
|
if ($ownershipResponse !== null) {
|
||||||
|
return $ownershipResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$nodes = $bulkCreateNodesUseCase->execute(new BulkCreateNodesRequest(
|
$nodes = $bulkCreateNodesUseCase->execute(new BulkCreateNodesRequest(
|
||||||
textId: $textId,
|
textId: $textId,
|
||||||
|
|
@ -113,4 +175,44 @@ class NodeController
|
||||||
$response->getBody()->write(json_encode(array_values($result)));
|
$response->getBody()->write(json_encode(array_values($result)));
|
||||||
return $response->withStatus(201)->withHeader('Content-Type', 'application/json');
|
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;
|
namespace App\Text;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
class CreateTextDto
|
class CreateTextDto
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $name,
|
public string $name,
|
||||||
|
public User $user,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,17 @@ namespace App\Text;
|
||||||
use App\Text\Text;
|
use App\Text\Text;
|
||||||
use App\Text\CreateTextDto;
|
use App\Text\CreateTextDto;
|
||||||
use App\Text\TextRepository;
|
use App\Text\TextRepository;
|
||||||
|
use App\User\User;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
class JsonTextRepository implements TextRepository
|
class JsonTextRepository implements TextRepository
|
||||||
{
|
{
|
||||||
private string $filePath;
|
private string $filePath;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct(
|
||||||
{
|
private UserRepository $userRepo,
|
||||||
|
) {
|
||||||
$this->filePath = __DIR__ . '/../../data/texts.json';
|
$this->filePath = __DIR__ . '/../../data/texts.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,8 +24,16 @@ class JsonTextRepository implements TextRepository
|
||||||
$texts = $this->readTexts();
|
$texts = $this->readTexts();
|
||||||
$id = $this->getNextId($texts);
|
$id = $this->getNextId($texts);
|
||||||
|
|
||||||
$text = new Text(id: $id, name: $dto->name);
|
$text = new Text(
|
||||||
$texts[] = ['id' => $id, 'name' => $dto->name];
|
id: $id,
|
||||||
|
name: $dto->name,
|
||||||
|
user: $dto->user,
|
||||||
|
);
|
||||||
|
$texts[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $dto->name,
|
||||||
|
'userId' => $dto->user->getId(),
|
||||||
|
];
|
||||||
|
|
||||||
$this->writeTexts($texts);
|
$this->writeTexts($texts);
|
||||||
|
|
||||||
|
|
@ -34,10 +46,7 @@ class JsonTextRepository implements TextRepository
|
||||||
|
|
||||||
foreach ($texts as $data) {
|
foreach ($texts as $data) {
|
||||||
if ($data['id'] === $id) {
|
if ($data['id'] === $id) {
|
||||||
return new Text(
|
return $this->hydrate($data);
|
||||||
id: $data['id'],
|
|
||||||
name: $data['name'],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,15 +62,50 @@ class JsonTextRepository implements TextRepository
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
function (array $data) {
|
function (array $data) {
|
||||||
return new Text(
|
return $this->hydrate($data);
|
||||||
id: $data['id'],
|
|
||||||
name: $data['name'],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
$texts
|
$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
|
* @return array
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@
|
||||||
|
|
||||||
namespace App\Text;
|
namespace App\Text;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
class Text
|
class Text
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private int $id,
|
private int $id,
|
||||||
private string $name,
|
private string $name,
|
||||||
|
private User $user,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getId(): int
|
public function getId(): int
|
||||||
|
|
@ -18,4 +21,9 @@ class Text
|
||||||
{
|
{
|
||||||
return $this->name;
|
return $this->name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUser(): User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Text;
|
namespace App\Text;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
use App\Exceptions\BadRequestException;
|
use App\Exceptions\BadRequestException;
|
||||||
use App\Text\TextRepository;
|
use App\Text\TextRepository;
|
||||||
use App\Text\UseCases\CreateText;
|
use App\Text\UseCases\CreateText;
|
||||||
|
|
@ -15,7 +16,7 @@ class TextController
|
||||||
private TextRepository $textRepository,
|
private TextRepository $textRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getTexts(Response $response): Response
|
public function getAllTexts(Response $response): Response
|
||||||
{
|
{
|
||||||
$texts = $this->textRepository->getAll();
|
$texts = $this->textRepository->getAll();
|
||||||
|
|
||||||
|
|
@ -30,14 +31,63 @@ class TextController
|
||||||
return $response->withHeader('Content-Type', 'application/json');
|
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);
|
$text = $this->textRepository->find($textId);
|
||||||
|
|
||||||
if ($text === null) {
|
if ($text === null) {
|
||||||
return $response->withStatus(404);
|
return $response->withStatus(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$text->getUser()->getId() !== $user->getId()
|
||||||
|
&& !$user->isAdmin()
|
||||||
|
) {
|
||||||
|
return $this->errorResponse(
|
||||||
|
$response,
|
||||||
|
403,
|
||||||
|
'forbidden'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$response->getBody()->write(json_encode([
|
$response->getBody()->write(json_encode([
|
||||||
'id' => $text->getId(),
|
'id' => $text->getId(),
|
||||||
'name' => $text->getName(),
|
'name' => $text->getName(),
|
||||||
|
|
@ -52,10 +102,19 @@ class TextController
|
||||||
): Response {
|
): Response {
|
||||||
$data = $request->getParsedBody();
|
$data = $request->getParsedBody();
|
||||||
$name = $data['name'] ?? null;
|
$name = $data['name'] ?? null;
|
||||||
|
$user = $request->getAttribute('user');
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return $this->errorResponse(
|
||||||
|
$response,
|
||||||
|
401,
|
||||||
|
'unauthenticated'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$text = $createTextUseCase->execute(new CreateTextRequest(
|
$text = $createTextUseCase->execute(new CreateTextRequest(
|
||||||
name: $name,
|
name: $name,
|
||||||
|
user: $user,
|
||||||
));
|
));
|
||||||
} catch (BadRequestException $e) {
|
} catch (BadRequestException $e) {
|
||||||
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
||||||
|
|
@ -68,4 +127,17 @@ class TextController
|
||||||
]));
|
]));
|
||||||
return $response->withHeader('Content-Type', 'application/json');
|
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\Text;
|
||||||
use App\Text\CreateTextDto;
|
use App\Text\CreateTextDto;
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
interface TextRepository
|
interface TextRepository
|
||||||
{
|
{
|
||||||
|
|
@ -15,4 +16,9 @@ interface TextRepository
|
||||||
* @return Text[]
|
* @return Text[]
|
||||||
*/
|
*/
|
||||||
public function getAll(): array;
|
public function getAll(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Text[]
|
||||||
|
*/
|
||||||
|
public function findByUser(User $user): array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,13 @@ class CreateText
|
||||||
if ($request->name === null) {
|
if ($request->name === null) {
|
||||||
throw new BadRequestException('name is required');
|
throw new BadRequestException('name is required');
|
||||||
}
|
}
|
||||||
|
if ($request->user === null) {
|
||||||
|
throw new BadRequestException('user is required');
|
||||||
|
}
|
||||||
|
|
||||||
$text = $this->textRepo->create(new CreateTextDto(
|
$text = $this->textRepo->create(new CreateTextDto(
|
||||||
name: $request->name,
|
name: $request->name,
|
||||||
|
user: $request->user,
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->nodeRepo->create(new CreateNodeDto(
|
$this->nodeRepo->create(new CreateNodeDto(
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
namespace App\Text\UseCases;
|
namespace App\Text\UseCases;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
class CreateTextRequest
|
class CreateTextRequest
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public ?string $name,
|
public ?string $name,
|
||||||
|
public ?User $user,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,26 @@ class ViewController
|
||||||
return $response;
|
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
|
public function home(Response $response): Response
|
||||||
{
|
{
|
||||||
$html = file_get_contents(__DIR__ . '/../../views/templates/home.php', true);
|
$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) {
|
$app->group('', function (RouteCollectorProxy $group) {
|
||||||
$group->get('/home', [ViewController::class, 'home']);
|
$group->get('/home', [ViewController::class, 'home']);
|
||||||
$group->get('/today', [ViewController::class, 'today']);
|
$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->post('/api/auth/logout', [AuthController::class, 'logout']);
|
||||||
$group->get('/api/auth/me', [AuthController::class, 'me']);
|
$group->get('/api/auth/me', [AuthController::class, 'me']);
|
||||||
|
|
||||||
$group->get('/api/texts', [TextController::class, 'getTexts']);
|
$group->get('/api/texts', [TextController::class, 'getMyTexts']);
|
||||||
$group->get(
|
$group->get(
|
||||||
'/api/texts/{textId}',
|
'/api/texts/{textId}',
|
||||||
[TextController::class, 'getText']
|
[TextController::class, 'getText']
|
||||||
);
|
);
|
||||||
|
$group->post('/api/texts', [TextController::class, 'createText']);
|
||||||
|
|
||||||
$group->get(
|
$group->get(
|
||||||
'/api/nodes/{textId}',
|
'/api/nodes/{textId}',
|
||||||
[NodeController::class, 'getNodesOfText']
|
[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']);
|
$group->post('/api/plans', [PlanController::class, 'createPlan']);
|
||||||
|
|
||||||
|
|
@ -61,12 +69,7 @@ $app->group('', function (RouteCollectorProxy $group) {
|
||||||
[ViewController::class, 'text']
|
[ViewController::class, 'text']
|
||||||
);
|
);
|
||||||
|
|
||||||
$group->post('/api/texts', [TextController::class, 'createText']);
|
$group->get('/api/admin/texts', [TextController::class, 'getAllTexts']);
|
||||||
$group->post(
|
|
||||||
'/api/nodes/bulk',
|
|
||||||
[NodeController::class, 'bulkCreateNodes']
|
|
||||||
);
|
|
||||||
$group->post('/api/nodes', [NodeController::class, 'createNode']);
|
|
||||||
})->add(AdminMiddleware::class)->add(AuthMiddleware::class);
|
})->add(AdminMiddleware::class)->add(AuthMiddleware::class);
|
||||||
|
|
||||||
return $app;
|
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', () => {
|
Cypress.Commands.add('loginAsUser', () => {
|
||||||
cy.login('user@example.com', 'password1')
|
cy.login('user@example.com', 'password1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Cypress.Commands.add('loginAsSecondUser', () => {
|
||||||
|
cy.login('user2@example.com', 'password2')
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ $texts = [
|
||||||
[
|
[
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
'name' => 'Tanach',
|
'name' => 'Tanach',
|
||||||
|
'userId' => 1,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ $nodes = [
|
||||||
// Default credentials:
|
// Default credentials:
|
||||||
// admin@example.com / admin1234 (admin)
|
// admin@example.com / admin1234 (admin)
|
||||||
// user@example.com / password1 (regular user)
|
// user@example.com / password1 (regular user)
|
||||||
|
// user2@example.com / password2 (second regular user, no texts seeded)
|
||||||
$users = [
|
$users = [
|
||||||
[
|
[
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
|
|
@ -44,6 +46,12 @@ $users = [
|
||||||
'passwordHash' => password_hash('password1', PASSWORD_DEFAULT),
|
'passwordHash' => password_hash('password1', PASSWORD_DEFAULT),
|
||||||
'isAdmin' => false,
|
'isAdmin' => false,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'id' => 2,
|
||||||
|
'email' => 'user2@example.com',
|
||||||
|
'passwordHash' => password_hash('password2', PASSWORD_DEFAULT),
|
||||||
|
'isAdmin' => false,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$plans = [];
|
$plans = [];
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const form = document.getElementById('texts-form');
|
const form = document.getElementById('texts-form');
|
||||||
|
|
||||||
async function loadTexts() {
|
async function loadTexts() {
|
||||||
const res = await fetch('/api/texts', {
|
const res = await fetch('/api/admin/texts', {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
const texts = await res.json();
|
const texts = await res.json();
|
||||||
textsList.innerHTML = texts.map(text =>
|
textsList.innerHTML = texts.map(function (text) {
|
||||||
'<li class="card"><a class="card-link"'
|
return '<li class="card"><a class="card-link"'
|
||||||
+ ' href=/admin/texts/'
|
+ ' href=/admin/texts/'
|
||||||
+ text.id
|
+ text.id
|
||||||
+ '>'
|
+ '>'
|
||||||
+ text.name
|
+ text.name
|
||||||
+ '</a></li>').join('');
|
+ '</a></li>';
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
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\CreateTextDto;
|
||||||
use App\Text\Text;
|
use App\Text\Text;
|
||||||
use App\Text\TextRepository;
|
use App\Text\TextRepository;
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
class FakeTextRepository implements TextRepository
|
class FakeTextRepository implements TextRepository
|
||||||
{
|
{
|
||||||
|
|
@ -19,6 +20,7 @@ class FakeTextRepository implements TextRepository
|
||||||
$text = new Text(
|
$text = new Text(
|
||||||
id: $id,
|
id: $id,
|
||||||
name: $dto->name,
|
name: $dto->name,
|
||||||
|
user: $dto->user,
|
||||||
);
|
);
|
||||||
$this->existingTexts[$id] = $text;
|
$this->existingTexts[$id] = $text;
|
||||||
|
|
||||||
|
|
@ -27,19 +29,15 @@ class FakeTextRepository implements TextRepository
|
||||||
|
|
||||||
public function find(int $id): ?Text
|
public function find(int $id): ?Text
|
||||||
{
|
{
|
||||||
$text = array_find(
|
if (!isset($this->existingTexts[$id])) {
|
||||||
$this->existingTexts,
|
|
||||||
function (Text $text) use ($id) {
|
|
||||||
return $text->getId() === $id;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if ($text === null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
$text = $this->existingTexts[$id];
|
||||||
|
|
||||||
return new Text(
|
return new Text(
|
||||||
id: $id,
|
id: $text->getId(),
|
||||||
name: $text->getName(),
|
name: $text->getName(),
|
||||||
|
user: $text->getUser(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,9 +56,35 @@ class FakeTextRepository implements TextRepository
|
||||||
return new Text(
|
return new Text(
|
||||||
id: $text->getId(),
|
id: $text->getId(),
|
||||||
name: $text->getName(),
|
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\BulkCreateNodes;
|
||||||
use App\Node\UseCases\BulkCreateNodesRequest;
|
use App\Node\UseCases\BulkCreateNodesRequest;
|
||||||
use App\Text\CreateTextDto;
|
use App\Text\CreateTextDto;
|
||||||
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\ValueObjects\EmailAddress;
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Tests\Fakes\FakeNodeRepository;
|
use Tests\Fakes\FakeNodeRepository;
|
||||||
use Tests\Fakes\FakeTextRepository;
|
use Tests\Fakes\FakeTextRepository;
|
||||||
|
use Tests\Fakes\FakeUserRepository;
|
||||||
|
|
||||||
class BulkCreateNodesTest extends TestCase
|
class BulkCreateNodesTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -22,8 +25,14 @@ class BulkCreateNodesTest extends TestCase
|
||||||
|
|
||||||
public function setUp(): void
|
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 = new FakeTextRepository();
|
||||||
$this->textRepo->create(new CreateTextDto(name: 'text'));
|
$this->textRepo->create(new CreateTextDto(name: 'text', user: $user));
|
||||||
|
|
||||||
$this->nodeRepo = new FakeNodeRepository();
|
$this->nodeRepo = new FakeNodeRepository();
|
||||||
$text = $this->textRepo->find(0);
|
$text = $this->textRepo->find(0);
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,13 @@ use App\Node\UseCases\CreateNode;
|
||||||
use App\Node\UseCases\CreateNodeRequest;
|
use App\Node\UseCases\CreateNodeRequest;
|
||||||
use App\Text\CreateTextDto;
|
use App\Text\CreateTextDto;
|
||||||
use App\Text\Text;
|
use App\Text\Text;
|
||||||
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\ValueObjects\EmailAddress;
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Tests\Fakes\FakeNodeRepository;
|
use Tests\Fakes\FakeNodeRepository;
|
||||||
use Tests\Fakes\FakeTextRepository;
|
use Tests\Fakes\FakeTextRepository;
|
||||||
|
use Tests\Fakes\FakeUserRepository;
|
||||||
|
|
||||||
class CreateNodeTest extends TestCase
|
class CreateNodeTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -22,9 +25,16 @@ class CreateNodeTest extends TestCase
|
||||||
|
|
||||||
public function setUp(): void
|
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 = new FakeTextRepository();
|
||||||
$this->textRepo->create(new CreateTextDto(
|
$this->textRepo->create(new CreateTextDto(
|
||||||
name: 'text'
|
name: 'text',
|
||||||
|
user: $user,
|
||||||
));
|
));
|
||||||
$this->nodeRepo = new FakeNodeRepository();
|
$this->nodeRepo = new FakeNodeRepository();
|
||||||
$this->useCase = new CreateNode(
|
$this->useCase = new CreateNode(
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class CreatePlanTest extends TestCase
|
||||||
$this->textRepo = new FakeTextRepository();
|
$this->textRepo = new FakeTextRepository();
|
||||||
$this->nodeRepo = new FakeNodeRepository();
|
$this->nodeRepo = new FakeNodeRepository();
|
||||||
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
||||||
$this->userRepo->create(new CreateUserDto(
|
$user = $this->userRepo->create(new CreateUserDto(
|
||||||
email: new EmailAddress('test@test.com'),
|
email: new EmailAddress('test@test.com'),
|
||||||
passwordHash: '',
|
passwordHash: '',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
|
@ -47,7 +47,10 @@ class CreatePlanTest extends TestCase
|
||||||
planRepo: $this->planRepo,
|
planRepo: $this->planRepo,
|
||||||
nodeRepo: $this->nodeRepo,
|
nodeRepo: $this->nodeRepo,
|
||||||
);
|
);
|
||||||
$this->textRepo->create(new CreateTextDto('testname'));
|
$this->textRepo->create(new CreateTextDto(
|
||||||
|
name: 'testname',
|
||||||
|
user: $user,
|
||||||
|
));
|
||||||
$this->useCase = new CreatePlan(
|
$this->useCase = new CreatePlan(
|
||||||
$this->planRepo,
|
$this->planRepo,
|
||||||
$this->userRepo,
|
$this->userRepo,
|
||||||
|
|
|
||||||
|
|
@ -35,19 +35,20 @@ class CreateScheduledNodeTest extends TestCase
|
||||||
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
||||||
$this->planRepo = new FakePlanRepository();
|
$this->planRepo = new FakePlanRepository();
|
||||||
$this->nodeRepo = new FakeNodeRepository();
|
$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(
|
$this->nodeRepo->create(new CreateNodeDto(
|
||||||
text: new Text(0, 'text name'),
|
text: new Text(0, 'text name', $user),
|
||||||
title: 'test node',
|
title: 'test node',
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
));
|
));
|
||||||
$this->planRepo->create(new CreatePlanDto(
|
$this->planRepo->create(new CreatePlanDto(
|
||||||
name: 'testplan',
|
name: 'testplan',
|
||||||
user: new User(
|
user: $user,
|
||||||
id: 0,
|
|
||||||
email: new EmailAddress('test@test.com'),
|
|
||||||
passwordHash: 'hashed:password1',
|
|
||||||
isAdmin: false,
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
$this->useCase = new CreateScheduledNode(
|
$this->useCase = new CreateScheduledNode(
|
||||||
$this->scheduledNodeRepo,
|
$this->scheduledNodeRepo,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use App\ScheduledNode\UseCases\GetTodaysSchedule;
|
||||||
use App\ScheduledNode\UseCases\GetTodaysScheduleRequest;
|
use App\ScheduledNode\UseCases\GetTodaysScheduleRequest;
|
||||||
use App\Text\Text;
|
use App\Text\Text;
|
||||||
use App\User\UseCases\CreateUserDto;
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
use App\ValueObjects\EmailAddress;
|
use App\ValueObjects\EmailAddress;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DomainException;
|
use DomainException;
|
||||||
|
|
@ -29,19 +30,21 @@ class GetTodaysScheduleTest extends TestCase
|
||||||
|
|
||||||
private GetTodaysSchedule $useCase;
|
private GetTodaysSchedule $useCase;
|
||||||
|
|
||||||
|
private User $user;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->userRepo = new FakeUserRepository();
|
$this->userRepo = new FakeUserRepository();
|
||||||
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
||||||
$this->planRepo = new FakePlanRepository();
|
$this->planRepo = new FakePlanRepository();
|
||||||
$user = $this->userRepo->create(new CreateUserDto(
|
$this->user = $this->userRepo->create(new CreateUserDto(
|
||||||
email: new EmailAddress('email@email.com'),
|
email: new EmailAddress('email@email.com'),
|
||||||
passwordHash: 'hash',
|
passwordHash: 'hash',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
));
|
));
|
||||||
$plan = $this->planRepo->create(new CreatePlanDto(
|
$plan = $this->planRepo->create(new CreatePlanDto(
|
||||||
name: 'test plan',
|
name: 'test plan',
|
||||||
user: $user,
|
user: $this->user,
|
||||||
));
|
));
|
||||||
$this->scheduledNodeRepo->create(new CreateScheduledNodeDto(
|
$this->scheduledNodeRepo->create(new CreateScheduledNodeDto(
|
||||||
date: new DateTimeImmutable('2025-01-02'),
|
date: new DateTimeImmutable('2025-01-02'),
|
||||||
|
|
@ -49,7 +52,7 @@ class GetTodaysScheduleTest extends TestCase
|
||||||
node: new Node(
|
node: new Node(
|
||||||
id: 0,
|
id: 0,
|
||||||
title: 'test node',
|
title: 'test node',
|
||||||
text: new Text(id: 0, name: 'test text'),
|
text: new Text(id: 0, name: 'test text', user: $this->user),
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
@ -79,7 +82,7 @@ class GetTodaysScheduleTest extends TestCase
|
||||||
node: new Node(
|
node: new Node(
|
||||||
id: 0,
|
id: 0,
|
||||||
title: 'test node',
|
title: 'test node',
|
||||||
text: new Text(id: 0, name: 'test text'),
|
text: new Text(id: 0, name: 'test text', user: $this->user),
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
@ -99,7 +102,7 @@ class GetTodaysScheduleTest extends TestCase
|
||||||
node: new Node(
|
node: new Node(
|
||||||
id: 0,
|
id: 0,
|
||||||
title: 'test node',
|
title: 'test node',
|
||||||
text: new Text(id: 0, name: 'test text'),
|
text: new Text(id: 0, name: 'test text', user: $this->user),
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -172,7 +175,7 @@ class GetTodaysScheduleTest extends TestCase
|
||||||
node: new Node(
|
node: new Node(
|
||||||
id: 0,
|
id: 0,
|
||||||
title: 'future node',
|
title: 'future node',
|
||||||
text: new Text(id: 0, name: 'test text'),
|
text: new Text(id: 0, name: 'test text', user: $this->user),
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
@ -202,7 +205,7 @@ class GetTodaysScheduleTest extends TestCase
|
||||||
node: new Node(
|
node: new Node(
|
||||||
id: 0,
|
id: 0,
|
||||||
title: 'other node',
|
title: 'other node',
|
||||||
text: new Text(id: 0, name: 'test text'),
|
text: new Text(id: 0, name: 'test text', user: $otherUser),
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,36 @@ use App\Text\Text;
|
||||||
use App\Text\TextRepository;
|
use App\Text\TextRepository;
|
||||||
use App\Text\UseCases\CreateText;
|
use App\Text\UseCases\CreateText;
|
||||||
use App\Text\UseCases\CreateTextRequest;
|
use App\Text\UseCases\CreateTextRequest;
|
||||||
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
|
use App\ValueObjects\EmailAddress;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Tests\Fakes\FakeNodeRepository;
|
use Tests\Fakes\FakeNodeRepository;
|
||||||
use Tests\Fakes\FakeTextRepository;
|
use Tests\Fakes\FakeTextRepository;
|
||||||
|
use Tests\Fakes\FakeUserRepository;
|
||||||
|
|
||||||
class CreateTextTest extends TestCase
|
class CreateTextTest extends TestCase
|
||||||
{
|
{
|
||||||
private FakeTextRepository $textRepo;
|
private FakeTextRepository $textRepo;
|
||||||
|
|
||||||
|
private FakeUserRepository $userRepo;
|
||||||
|
|
||||||
private FakeNodeRepository $nodeRepo;
|
private FakeNodeRepository $nodeRepo;
|
||||||
|
|
||||||
private CreateText $useCase;
|
private CreateText $useCase;
|
||||||
|
|
||||||
|
private User $user;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->textRepo = new FakeTextRepository();
|
$this->textRepo = new FakeTextRepository();
|
||||||
$this->nodeRepo = new FakeNodeRepository();
|
$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->useCase = new CreateText(
|
||||||
$this->textRepo,
|
$this->textRepo,
|
||||||
$this->nodeRepo,
|
$this->nodeRepo,
|
||||||
|
|
@ -33,6 +47,7 @@ class CreateTextTest extends TestCase
|
||||||
{
|
{
|
||||||
$text = $this->useCase->execute(new CreateTextRequest(
|
$text = $this->useCase->execute(new CreateTextRequest(
|
||||||
name: 'test',
|
name: 'test',
|
||||||
|
user: $this->user,
|
||||||
));
|
));
|
||||||
$this->assertInstanceOf(TextRepository::class, $this->textRepo);
|
$this->assertInstanceOf(TextRepository::class, $this->textRepo);
|
||||||
$this->assertInstanceOf(Text::class, $text);
|
$this->assertInstanceOf(Text::class, $text);
|
||||||
|
|
@ -43,6 +58,7 @@ class CreateTextTest extends TestCase
|
||||||
{
|
{
|
||||||
$text = $this->useCase->execute(new CreateTextRequest(
|
$text = $this->useCase->execute(new CreateTextRequest(
|
||||||
name: 'my text',
|
name: 'my text',
|
||||||
|
user: $this->user,
|
||||||
));
|
));
|
||||||
|
|
||||||
$nodes = $this->nodeRepo->findByTextId($text->getId());
|
$nodes = $this->nodeRepo->findByTextId($text->getId());
|
||||||
|
|
@ -53,6 +69,17 @@ class CreateTextTest extends TestCase
|
||||||
$this->assertNull($rootNode->getParentNode());
|
$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
|
public function test_throws_if_name_is_null(): void
|
||||||
{
|
{
|
||||||
$this->expectException(BadRequestException::class);
|
$this->expectException(BadRequestException::class);
|
||||||
|
|
@ -60,6 +87,18 @@ class CreateTextTest extends TestCase
|
||||||
|
|
||||||
$this->useCase->execute(new CreateTextRequest(
|
$this->useCase->execute(new CreateTextRequest(
|
||||||
name: null,
|
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\NodeController;
|
||||||
use App\Node\UseCases\BulkCreateNodes;
|
use App\Node\UseCases\BulkCreateNodes;
|
||||||
use App\Text\CreateTextDto;
|
use App\Text\CreateTextDto;
|
||||||
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
|
use App\ValueObjects\EmailAddress;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||||
|
|
@ -13,6 +16,7 @@ use Slim\Psr7\Factory\StreamFactory;
|
||||||
use Slim\Psr7\Response;
|
use Slim\Psr7\Response;
|
||||||
use Tests\Fakes\FakeNodeRepository;
|
use Tests\Fakes\FakeNodeRepository;
|
||||||
use Tests\Fakes\FakeTextRepository;
|
use Tests\Fakes\FakeTextRepository;
|
||||||
|
use Tests\Fakes\FakeUserRepository;
|
||||||
|
|
||||||
class BulkCreateNodesControllerTest extends TestCase
|
class BulkCreateNodesControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -20,11 +24,33 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
private FakeNodeRepository $nodeRepo;
|
private FakeNodeRepository $nodeRepo;
|
||||||
private BulkCreateNodes $useCase;
|
private BulkCreateNodes $useCase;
|
||||||
private NodeController $controller;
|
private NodeController $controller;
|
||||||
|
private User $user;
|
||||||
|
private User $otherUser;
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
public function setUp(): void
|
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 = 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 = new FakeNodeRepository();
|
||||||
$this->nodeRepo->create(new CreateNodeDto(
|
$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));
|
$body = new StreamFactory()->createStream(json_encode($data));
|
||||||
return new ServerRequestFactory()
|
$request = new ServerRequestFactory()
|
||||||
->createServerRequest('POST', 'http://localhost/api/nodes/bulk')
|
->createServerRequest('POST', 'http://localhost/api/nodes/bulk')
|
||||||
->withHeader('Content-Type', 'application/json')
|
->withHeader('Content-Type', 'application/json')
|
||||||
->withBody($body);
|
->withBody($body);
|
||||||
|
return $request->withAttribute('user', $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_bulk_create_nodes_returns_201_with_created_nodes(): void
|
public function test_bulk_create_nodes_returns_201_with_created_nodes(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => 0,
|
'textId' => 0,
|
||||||
'titlePrefix' => 'Page',
|
'parentNodeId' => 0,
|
||||||
'count' => 3,
|
'titlePrefix' => 'Page',
|
||||||
]),
|
'count' => 3,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -72,12 +104,15 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_returns_correct_count(): void
|
public function test_bulk_create_nodes_returns_correct_count(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => 0,
|
'textId' => 0,
|
||||||
'titlePrefix' => 'Page',
|
'parentNodeId' => 0,
|
||||||
'count' => 10,
|
'titlePrefix' => 'Page',
|
||||||
]),
|
'count' => 10,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -89,12 +124,15 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_returns_correct_titles(): void
|
public function test_bulk_create_nodes_returns_correct_titles(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => 0,
|
'textId' => 0,
|
||||||
'titlePrefix' => 'Chapter',
|
'parentNodeId' => 0,
|
||||||
'count' => 3,
|
'titlePrefix' => 'Chapter',
|
||||||
]),
|
'count' => 3,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -108,12 +146,15 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_response_includes_id_and_parent_node_id(): void
|
public function test_bulk_create_nodes_response_includes_id_and_parent_node_id(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => 0,
|
'textId' => 0,
|
||||||
'titlePrefix' => 'Page',
|
'parentNodeId' => 0,
|
||||||
'count' => 2,
|
'titlePrefix' => 'Page',
|
||||||
]),
|
'count' => 2,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -126,11 +167,14 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_returns_400_when_title_prefix_missing(): void
|
public function test_bulk_create_nodes_returns_400_when_title_prefix_missing(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => 0,
|
'textId' => 0,
|
||||||
'count' => 5,
|
'parentNodeId' => 0,
|
||||||
]),
|
'count' => 5,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -141,12 +185,15 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_returns_400_when_count_is_zero(): void
|
public function test_bulk_create_nodes_returns_400_when_count_is_zero(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => 0,
|
'textId' => 0,
|
||||||
'titlePrefix' => 'Page',
|
'parentNodeId' => 0,
|
||||||
'count' => 0,
|
'titlePrefix' => 'Page',
|
||||||
]),
|
'count' => 0,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -157,11 +204,14 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_returns_400_when_count_is_missing(): void
|
public function test_bulk_create_nodes_returns_400_when_count_is_missing(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => 0,
|
'textId' => 0,
|
||||||
'titlePrefix' => 'Page',
|
'parentNodeId' => 0,
|
||||||
]),
|
'titlePrefix' => 'Page',
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -172,11 +222,14 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_returns_400_when_parent_node_id_missing(): void
|
public function test_bulk_create_nodes_returns_400_when_parent_node_id_missing(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'titlePrefix' => 'Page',
|
'textId' => 0,
|
||||||
'count' => 5,
|
'titlePrefix' => 'Page',
|
||||||
]),
|
'count' => 5,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -187,12 +240,15 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_returns_404_when_text_not_found(): void
|
public function test_bulk_create_nodes_returns_404_when_text_not_found(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 99,
|
[
|
||||||
'parentNodeId' => 0,
|
'textId' => 99,
|
||||||
'titlePrefix' => 'Page',
|
'parentNodeId' => 0,
|
||||||
'count' => 5,
|
'titlePrefix' => 'Page',
|
||||||
]),
|
'count' => 5,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
@ -203,16 +259,57 @@ class BulkCreateNodesControllerTest extends TestCase
|
||||||
public function test_bulk_create_nodes_returns_404_when_parent_node_not_found(): void
|
public function test_bulk_create_nodes_returns_404_when_parent_node_not_found(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->bulkCreateNodes(
|
$response = $this->controller->bulkCreateNodes(
|
||||||
$this->makeRequest([
|
$this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => 99,
|
'textId' => 0,
|
||||||
'titlePrefix' => 'Page',
|
'parentNodeId' => 99,
|
||||||
'count' => 5,
|
'titlePrefix' => 'Page',
|
||||||
]),
|
'count' => 5,
|
||||||
|
],
|
||||||
|
$this->user,
|
||||||
|
),
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->useCase,
|
$this->useCase,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertEquals(404, $response->getStatusCode());
|
$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\NodeController;
|
||||||
use App\Node\UseCases\CreateNode;
|
use App\Node\UseCases\CreateNode;
|
||||||
use App\Text\CreateTextDto;
|
use App\Text\CreateTextDto;
|
||||||
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
|
use App\ValueObjects\EmailAddress;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||||
use Slim\Psr7\Factory\StreamFactory;
|
use Slim\Psr7\Factory\StreamFactory;
|
||||||
use Slim\Psr7\Response;
|
use Slim\Psr7\Response;
|
||||||
use Tests\Fakes\FakeNodeRepository;
|
use Tests\Fakes\FakeNodeRepository;
|
||||||
use Tests\Fakes\FakeTextRepository;
|
use Tests\Fakes\FakeTextRepository;
|
||||||
|
use Tests\Fakes\FakeUserRepository;
|
||||||
|
|
||||||
class NodeControllerTest extends TestCase
|
class NodeControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
private FakeTextRepository $textRepo;
|
private FakeTextRepository $textRepo;
|
||||||
private FakeNodeRepository $nodeRepo;
|
private FakeNodeRepository $nodeRepo;
|
||||||
private NodeController $controller;
|
private NodeController $controller;
|
||||||
|
private User $user;
|
||||||
|
private User $otherUser;
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
public function setUp(): void
|
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 = 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->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
|
public function test_get_nodes_of_text_returns_flat_array(): void
|
||||||
|
|
@ -38,7 +93,11 @@ class NodeControllerTest extends TestCase
|
||||||
parentNode: null,
|
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(200, $response->getStatusCode());
|
||||||
$this->assertEquals(
|
$this->assertEquals(
|
||||||
|
|
@ -51,7 +110,11 @@ class NodeControllerTest extends TestCase
|
||||||
|
|
||||||
public function test_get_nodes_of_text_returns_empty_array_when_no_nodes(): void
|
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(200, $response->getStatusCode());
|
||||||
$this->assertEquals(json_encode([]), $response->getBody());
|
$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
|
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());
|
$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
|
public function test_get_nodes_includes_parent_node_id(): void
|
||||||
{
|
{
|
||||||
$text = $this->textRepo->find(0);
|
$text = $this->textRepo->find(0);
|
||||||
|
|
@ -78,7 +174,11 @@ class NodeControllerTest extends TestCase
|
||||||
parentNode: $rootNode,
|
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);
|
$body = json_decode($response->getBody(), true);
|
||||||
|
|
||||||
$this->assertEquals(0, $body[1]['parentNodeId']);
|
$this->assertEquals(0, $body[1]['parentNodeId']);
|
||||||
|
|
@ -93,15 +193,14 @@ class NodeControllerTest extends TestCase
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
));
|
));
|
||||||
|
|
||||||
$body = new StreamFactory()->createStream(json_encode([
|
$request = $this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'title' => 'Child Node',
|
'textId' => 0,
|
||||||
'parentNodeId' => $rootNode->getId(),
|
'title' => 'Child Node',
|
||||||
]));
|
'parentNodeId' => $rootNode->getId(),
|
||||||
$request = new ServerRequestFactory()
|
],
|
||||||
->createServerRequest('POST', 'http://localhost/api/nodes')
|
$this->user,
|
||||||
->withHeader('Content-Type', 'application/json')
|
);
|
||||||
->withBody($body);
|
|
||||||
|
|
||||||
$response = $this->controller->createNode(
|
$response = $this->controller->createNode(
|
||||||
$request,
|
$request,
|
||||||
|
|
@ -118,14 +217,13 @@ class NodeControllerTest extends TestCase
|
||||||
|
|
||||||
public function test_create_node_returns_400_when_title_missing(): void
|
public function test_create_node_returns_400_when_title_missing(): void
|
||||||
{
|
{
|
||||||
$body = new StreamFactory()->createStream(json_encode([
|
$request = $this->makeRequest(
|
||||||
'textId' => 0,
|
[
|
||||||
'parentNodeId' => null,
|
'textId' => 0,
|
||||||
]));
|
'parentNodeId' => null,
|
||||||
$request = new ServerRequestFactory()
|
],
|
||||||
->createServerRequest('POST', 'http://localhost/api/nodes')
|
$this->user,
|
||||||
->withHeader('Content-Type', 'application/json')
|
);
|
||||||
->withBody($body);
|
|
||||||
|
|
||||||
$response = $this->controller->createNode(
|
$response = $this->controller->createNode(
|
||||||
$request,
|
$request,
|
||||||
|
|
@ -138,15 +236,14 @@ class NodeControllerTest extends TestCase
|
||||||
|
|
||||||
public function test_create_node_returns_404_when_text_not_found(): void
|
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',
|
'textId' => 99,
|
||||||
'parentNodeId' => null,
|
'title' => 'Some Node',
|
||||||
]));
|
'parentNodeId' => null,
|
||||||
$request = new ServerRequestFactory()
|
],
|
||||||
->createServerRequest('POST', 'http://localhost/api/nodes')
|
$this->user,
|
||||||
->withHeader('Content-Type', 'application/json')
|
);
|
||||||
->withBody($body);
|
|
||||||
|
|
||||||
$response = $this->controller->createNode(
|
$response = $this->controller->createNode(
|
||||||
$request,
|
$request,
|
||||||
|
|
@ -156,4 +253,44 @@ class NodeControllerTest extends TestCase
|
||||||
|
|
||||||
$this->assertEquals(404, $response->getStatusCode());
|
$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: '',
|
passwordHash: '',
|
||||||
isAdmin: false,
|
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(
|
$this->nodeRepo->create(new CreateNodeDto(
|
||||||
text: $text,
|
text: $text,
|
||||||
title: 'Root Node',
|
title: 'Root Node',
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class ScheduledNodeControllerTest extends TestCase
|
||||||
node: new Node(
|
node: new Node(
|
||||||
id: 0,
|
id: 0,
|
||||||
title: $nodeTitle,
|
title: $nodeTitle,
|
||||||
text: new Text(id: 0, name: 'test text'),
|
text: new Text(id: 0, name: 'test text', user: $user),
|
||||||
parentNode: null,
|
parentNode: null,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -5,30 +5,71 @@ namespace Tests\e2e\Controllers;
|
||||||
use App\Text\CreateTextDto;
|
use App\Text\CreateTextDto;
|
||||||
use App\Text\TextController;
|
use App\Text\TextController;
|
||||||
use App\Text\UseCases\CreateText;
|
use App\Text\UseCases\CreateText;
|
||||||
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
|
use App\ValueObjects\EmailAddress;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||||
use Slim\Psr7\Response;
|
use Slim\Psr7\Response;
|
||||||
use Tests\Fakes\FakeNodeRepository;
|
use Tests\Fakes\FakeNodeRepository;
|
||||||
use Tests\Fakes\FakeTextRepository;
|
use Tests\Fakes\FakeTextRepository;
|
||||||
|
use Tests\Fakes\FakeUserRepository;
|
||||||
|
|
||||||
class TextControllerTest extends TestCase
|
class TextControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
private FakeTextRepository $textRepo;
|
private FakeTextRepository $textRepo;
|
||||||
|
|
||||||
|
private FakeUserRepository $userRepo;
|
||||||
|
|
||||||
private TextController $controller;
|
private TextController $controller;
|
||||||
|
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
private User $otherUser;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->textRepo = new FakeTextRepository();
|
$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(
|
$this->textRepo->create(new CreateTextDto(
|
||||||
name: 'test text',
|
name: 'test text',
|
||||||
|
user: $this->user,
|
||||||
));
|
));
|
||||||
$this->controller = new TextController($this->textRepo);
|
$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
|
public function test_get_one_text(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->getText(
|
$response = $this->controller->getText(
|
||||||
|
$this->makeRequest($this->user),
|
||||||
new Response(),
|
new Response(),
|
||||||
0,
|
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(
|
$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(
|
$this->assertEquals(
|
||||||
json_encode([
|
json_encode([
|
||||||
[
|
[
|
||||||
|
|
@ -55,18 +134,57 @@ class TextControllerTest extends TestCase
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 1,
|
'id' => 1,
|
||||||
'name' => 'test text 2',
|
'name' => 'other users text',
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
$response->getBody()
|
$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
|
public function test_create_text(): void
|
||||||
{
|
{
|
||||||
$request = new ServerRequestFactory()
|
$request = new ServerRequestFactory()
|
||||||
->createServerRequest('POST', 'http://localhost/texts')
|
->createServerRequest('POST', 'http://localhost/texts')
|
||||||
->withParsedBody(['name' => 'my new text']);
|
->withParsedBody(['name' => 'my new text'])
|
||||||
|
->withAttribute('user', $this->user);
|
||||||
|
|
||||||
$response = $this->controller->createText(
|
$response = $this->controller->createText(
|
||||||
$request,
|
$request,
|
||||||
|
|
@ -89,7 +207,8 @@ class TextControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$request = new ServerRequestFactory()
|
$request = new ServerRequestFactory()
|
||||||
->createServerRequest('POST', 'http://localhost/texts')
|
->createServerRequest('POST', 'http://localhost/texts')
|
||||||
->withParsedBody([]);
|
->withParsedBody([])
|
||||||
|
->withAttribute('user', $this->user);
|
||||||
|
|
||||||
$response = $this->controller->createText(
|
$response = $this->controller->createText(
|
||||||
$request,
|
$request,
|
||||||
|
|
@ -104,4 +223,74 @@ class TextControllerTest extends TestCase
|
||||||
$body = json_decode($response->getBody(), true);
|
$body = json_decode($response->getBody(), true);
|
||||||
$this->assertArrayHasKey('error', $body);
|
$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">
|
<div class="site-header-inner">
|
||||||
<h1>Home</h1>
|
<h1>Home</h1>
|
||||||
<div class="cluster">
|
<div class="cluster">
|
||||||
|
<a class="btn btn-secondary" href="/texts" id="manage-texts">
|
||||||
|
My texts
|
||||||
|
</a>
|
||||||
<a class="btn btn-secondary" href="/today">
|
<a class="btn btn-secondary" href="/today">
|
||||||
Today's schedule
|
Today's schedule
|
||||||
</a>
|
</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