Merge branch 'text-has-user'

This commit is contained in:
Yisroel Baum 2026-05-02 22:40:39 +03:00
commit 3b29c7b90f
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
37 changed files with 1270 additions and 167 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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