From ffef0ddff6aefcd78327fc88c4d1d16e6c38d31a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:27:28 +0300 Subject: [PATCH 01/23] add user property to text entity --- app/Text/CreateTextDto.php | 3 +++ app/Text/Text.php | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/app/Text/CreateTextDto.php b/app/Text/CreateTextDto.php index 31e68e8..6dcda1e 100644 --- a/app/Text/CreateTextDto.php +++ b/app/Text/CreateTextDto.php @@ -2,9 +2,12 @@ namespace App\Text; +use App\User\User; + class CreateTextDto { public function __construct( public string $name, + public User $user, ) {} } diff --git a/app/Text/Text.php b/app/Text/Text.php index 63f4403..27031f0 100644 --- a/app/Text/Text.php +++ b/app/Text/Text.php @@ -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; + } } From bf006220e8286c028a978cf7d0bb4d498232ea4f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:27:32 +0300 Subject: [PATCH 02/23] pass user object to create text use case drop UserRepository dependency; controller now passes the authenticated User directly via CreateTextRequest, eliminating a redundant repository lookup. --- app/Text/UseCases/CreateText.php | 4 ++++ app/Text/UseCases/CreateTextRequest.php | 3 +++ 2 files changed, 7 insertions(+) diff --git a/app/Text/UseCases/CreateText.php b/app/Text/UseCases/CreateText.php index a976997..8240dc1 100644 --- a/app/Text/UseCases/CreateText.php +++ b/app/Text/UseCases/CreateText.php @@ -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( diff --git a/app/Text/UseCases/CreateTextRequest.php b/app/Text/UseCases/CreateTextRequest.php index 3a6c6a4..7324fbf 100644 --- a/app/Text/UseCases/CreateTextRequest.php +++ b/app/Text/UseCases/CreateTextRequest.php @@ -2,9 +2,12 @@ namespace App\Text\UseCases; +use App\User\User; + class CreateTextRequest { public function __construct( public ?string $name, + public ?User $user, ) {} } From bac832380636b6771d00ded30270897346e0e91a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:27:36 +0300 Subject: [PATCH 03/23] extract user from session in text controller prevent payload from spoofing ownership by reading the user from the request attribute set by auth middleware. respond 401 when unauthenticated. --- app/Text/TextController.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/Text/TextController.php b/app/Text/TextController.php index 3834f32..14230e1 100644 --- a/app/Text/TextController.php +++ b/app/Text/TextController.php @@ -2,6 +2,7 @@ namespace App\Text; +use App\User\User; use App\Exceptions\BadRequestException; use App\Text\TextRepository; use App\Text\UseCases\CreateText; @@ -52,10 +53,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 +78,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'); + } } From 4635fef3c71cb81bc79d1f99a3545b53f2a478cf Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:27:40 +0300 Subject: [PATCH 04/23] persist user id in json text repository store userId in the json record and rehydrate the User via UserRepository. throws DomainException if the referenced user no longer exists. --- app/Text/JsonTextRepository.php | 45 ++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/app/Text/JsonTextRepository.php b/app/Text/JsonTextRepository.php index 26c5e76..23c612d 100644 --- a/app/Text/JsonTextRepository.php +++ b/app/Text/JsonTextRepository.php @@ -5,13 +5,16 @@ namespace App\Text; use App\Text\Text; use App\Text\CreateTextDto; use App\Text\TextRepository; +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 +23,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 +45,7 @@ class JsonTextRepository implements TextRepository foreach ($texts as $data) { if ($data['id'] === $id) { - return new Text( - id: $data['id'], - name: $data['name'], - ); + return $this->hydrate($data); } } @@ -53,15 +61,28 @@ class JsonTextRepository implements TextRepository return array_map( function (array $data) { - return new Text( - id: $data['id'], - name: $data['name'], - ); + return $this->hydrate($data); }, $texts ); } + 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 */ From 6668240126dbe68eea507128f3e5e62bfbd92f4b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:27:45 +0300 Subject: [PATCH 05/23] update fake text repository for user include the user when rebuilding Text instances in find and getAll, preserving the rule that lookup methods return new instances rather than stored references. --- tests/Fakes/FakeTextRepository.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/Fakes/FakeTextRepository.php b/tests/Fakes/FakeTextRepository.php index 7dac270..3e556a2 100644 --- a/tests/Fakes/FakeTextRepository.php +++ b/tests/Fakes/FakeTextRepository.php @@ -19,6 +19,7 @@ class FakeTextRepository implements TextRepository $text = new Text( id: $id, name: $dto->name, + user: $dto->user, ); $this->existingTexts[$id] = $text; @@ -27,19 +28,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 +55,10 @@ class FakeTextRepository implements TextRepository return new Text( id: $text->getId(), name: $text->getName(), + user: $text->getUser(), ); }, - $this->existingTexts + array_values($this->existingTexts) ); } } From 40fdf25da28769f7d2375c1549b8e69581bb6322 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:27:49 +0300 Subject: [PATCH 06/23] add tests for text user relationship cover that the created Text carries the supplied User, that the controller persists the user from the session attribute, and that any userId in the request body is ignored. --- tests/Unit/Text/UseCases/CreateTextTest.php | 39 ++++++++ tests/e2e/Controllers/TextControllerTest.php | 97 +++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Text/UseCases/CreateTextTest.php b/tests/Unit/Text/UseCases/CreateTextTest.php index 279d6ab..bcc61a1 100644 --- a/tests/Unit/Text/UseCases/CreateTextTest.php +++ b/tests/Unit/Text/UseCases/CreateTextTest.php @@ -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, )); } } diff --git a/tests/e2e/Controllers/TextControllerTest.php b/tests/e2e/Controllers/TextControllerTest.php index ec5af99..16ddcf1 100644 --- a/tests/e2e/Controllers/TextControllerTest.php +++ b/tests/e2e/Controllers/TextControllerTest.php @@ -5,23 +5,38 @@ 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 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; + 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->textRepo->create(new CreateTextDto( name: 'test text', + user: $this->user, )); $this->controller = new TextController($this->textRepo); } @@ -45,6 +60,7 @@ class TextControllerTest extends TestCase { $this->textRepo->create(new CreateTextDto( name: 'test text 2', + user: $this->user, )); $response = $this->controller->getTexts(new Response()); $this->assertEquals( @@ -66,7 +82,8 @@ class TextControllerTest extends TestCase { $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 +106,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 +122,79 @@ 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 + { + $otherUser = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('other@b.com'), + passwordHash: '', + isAdmin: false, + )); + $request = new ServerRequestFactory() + ->createServerRequest('POST', 'http://localhost/texts') + ->withParsedBody([ + 'name' => 'my new text', + 'userId' => $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); + } } From cbbbc80326e17ab816f5bbf7bd210cc9e28049df Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:27:55 +0300 Subject: [PATCH 07/23] update downstream tests for text user requirement Text now requires a User on construction. seed a user in each test setUp that creates a Text directly or through the fake repository so the suite remains green. --- tests/Unit/Node/UseCases/BulkCreateNodesTest.php | 11 ++++++++++- tests/Unit/Node/UseCases/CreateNodeTest.php | 12 +++++++++++- tests/Unit/Plan/UseCases/CreatePlanTest.php | 7 +++++-- .../UseCases/CreateScheduledNodeTest.php | 15 ++++++++------- .../UseCases/GetTodaysScheduleTest.php | 16 +++++++++------- .../BulkCreateNodesControllerTest.php | 14 +++++++++++++- tests/e2e/Controllers/NodeControllerTest.php | 11 ++++++++++- tests/e2e/Controllers/PlanControllerTest.php | 5 ++++- .../Controllers/ScheduledNodeControllerTest.php | 2 +- 9 files changed, 71 insertions(+), 22 deletions(-) diff --git a/tests/Unit/Node/UseCases/BulkCreateNodesTest.php b/tests/Unit/Node/UseCases/BulkCreateNodesTest.php index 3fd3452..5faaa2b 100644 --- a/tests/Unit/Node/UseCases/BulkCreateNodesTest.php +++ b/tests/Unit/Node/UseCases/BulkCreateNodesTest.php @@ -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); diff --git a/tests/Unit/Node/UseCases/CreateNodeTest.php b/tests/Unit/Node/UseCases/CreateNodeTest.php index 5b9226e..7e47ea5 100644 --- a/tests/Unit/Node/UseCases/CreateNodeTest.php +++ b/tests/Unit/Node/UseCases/CreateNodeTest.php @@ -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( diff --git a/tests/Unit/Plan/UseCases/CreatePlanTest.php b/tests/Unit/Plan/UseCases/CreatePlanTest.php index fbdfc60..a08da10 100644 --- a/tests/Unit/Plan/UseCases/CreatePlanTest.php +++ b/tests/Unit/Plan/UseCases/CreatePlanTest.php @@ -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, diff --git a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php index 8c866fe..200b71a 100644 --- a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php +++ b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php @@ -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, diff --git a/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php b/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php index 359b632..5a2afc6 100644 --- a/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php +++ b/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php @@ -29,19 +29,21 @@ class GetTodaysScheduleTest extends TestCase private GetTodaysSchedule $useCase; + private \App\User\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 +51,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 +81,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 +101,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 +174,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 +204,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, ), )); diff --git a/tests/e2e/Controllers/BulkCreateNodesControllerTest.php b/tests/e2e/Controllers/BulkCreateNodesControllerTest.php index 8dfcb25..660217e 100644 --- a/tests/e2e/Controllers/BulkCreateNodesControllerTest.php +++ b/tests/e2e/Controllers/BulkCreateNodesControllerTest.php @@ -6,6 +6,8 @@ use App\Node\CreateNodeDto; use App\Node\NodeController; use App\Node\UseCases\BulkCreateNodes; use App\Text\CreateTextDto; +use App\User\UseCases\CreateUserDto; +use App\ValueObjects\EmailAddress; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Slim\Psr7\Factory\ServerRequestFactory; @@ -13,6 +15,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 { @@ -23,8 +26,17 @@ class BulkCreateNodesControllerTest 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(); - $text = $this->textRepo->create(new CreateTextDto(name: 'test text')); + $text = $this->textRepo->create(new CreateTextDto( + name: 'test text', + user: $user, + )); $this->nodeRepo = new FakeNodeRepository(); $this->nodeRepo->create(new CreateNodeDto( diff --git a/tests/e2e/Controllers/NodeControllerTest.php b/tests/e2e/Controllers/NodeControllerTest.php index fffb89f..c99b609 100644 --- a/tests/e2e/Controllers/NodeControllerTest.php +++ b/tests/e2e/Controllers/NodeControllerTest.php @@ -6,12 +6,15 @@ use App\Node\CreateNodeDto; use App\Node\NodeController; use App\Node\UseCases\CreateNode; use App\Text\CreateTextDto; +use App\User\UseCases\CreateUserDto; +use App\ValueObjects\EmailAddress; use PHPUnit\Framework\TestCase; 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 { @@ -21,8 +24,14 @@ class NodeControllerTest 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: 'test text')); + $this->textRepo->create(new CreateTextDto(name: 'test text', user: $user)); $this->nodeRepo = new FakeNodeRepository(); diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 818651c..08e42d8 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -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', diff --git a/tests/e2e/Controllers/ScheduledNodeControllerTest.php b/tests/e2e/Controllers/ScheduledNodeControllerTest.php index 0a7c57e..69b3975 100644 --- a/tests/e2e/Controllers/ScheduledNodeControllerTest.php +++ b/tests/e2e/Controllers/ScheduledNodeControllerTest.php @@ -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, ), )); From ea6d65a77d5808120ca6676cbd86d2481a6803c8 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:41:52 +0300 Subject: [PATCH 08/23] test text controller scoping and ownership add failing tests for getMyTexts (own-only), getAllTexts (admin), getText 403 for non-owner, and admin bypass on getText. existing test_get_one_text updated to pass the session user via the new request signature. --- tests/e2e/Controllers/TextControllerTest.php | 117 +++++++++++++++++-- 1 file changed, 106 insertions(+), 11 deletions(-) diff --git a/tests/e2e/Controllers/TextControllerTest.php b/tests/e2e/Controllers/TextControllerTest.php index 16ddcf1..6498568 100644 --- a/tests/e2e/Controllers/TextControllerTest.php +++ b/tests/e2e/Controllers/TextControllerTest.php @@ -25,6 +25,10 @@ class TextControllerTest extends TestCase private User $user; + private User $otherUser; + + private User $admin; + public function setUp(): void { $this->textRepo = new FakeTextRepository(); @@ -34,6 +38,16 @@ class TextControllerTest extends TestCase 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, @@ -41,9 +55,20 @@ class TextControllerTest extends TestCase $this->controller = new TextController($this->textRepo); } + private function makeRequest(?User $user): \Psr\Http\Message\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, ); @@ -56,13 +81,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', - user: $this->user, + name: 'other users text', + user: $this->otherUser, )); - $response = $this->controller->getTexts(new Response()); + $response = $this->controller->getAllTexts(new Response()); $this->assertEquals( json_encode([ [ @@ -71,13 +133,51 @@ 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() @@ -149,16 +249,11 @@ class TextControllerTest extends TestCase public function test_create_text_ignores_user_id_in_body(): void { - $otherUser = $this->userRepo->create(new CreateUserDto( - email: new EmailAddress('other@b.com'), - passwordHash: '', - isAdmin: false, - )); $request = new ServerRequestFactory() ->createServerRequest('POST', 'http://localhost/texts') ->withParsedBody([ 'name' => 'my new text', - 'userId' => $otherUser->getId(), + 'userId' => $this->otherUser->getId(), ]) ->withAttribute('user', $this->user); From acdf703d806b0d67d066cd72c36bd416ff729d74 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:42:51 +0300 Subject: [PATCH 09/23] scope text endpoints by ownership TextRepository gains findByUser; JsonTextRepository and the fake implement filtering by stored userId. TextController splits the list endpoint into getMyTexts (own) and getAllTexts (admin), and getText now requires the session user, returning 403 to non-owners while admins bypass. --- app/Text/JsonTextRepository.php | 23 +++++++++++++ app/Text/TextController.php | 55 ++++++++++++++++++++++++++++-- app/Text/TextRepository.php | 6 ++++ tests/Fakes/FakeTextRepository.php | 26 ++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/app/Text/JsonTextRepository.php b/app/Text/JsonTextRepository.php index 23c612d..22fa42e 100644 --- a/app/Text/JsonTextRepository.php +++ b/app/Text/JsonTextRepository.php @@ -5,6 +5,7 @@ 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; @@ -67,6 +68,28 @@ class JsonTextRepository implements TextRepository ); } + /** + * @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']); diff --git a/app/Text/TextController.php b/app/Text/TextController.php index 14230e1..9e88955 100644 --- a/app/Text/TextController.php +++ b/app/Text/TextController.php @@ -16,7 +16,7 @@ class TextController private TextRepository $textRepository, ) {} - public function getTexts(Response $response): Response + public function getAllTexts(Response $response): Response { $texts = $this->textRepository->getAll(); @@ -31,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(), diff --git a/app/Text/TextRepository.php b/app/Text/TextRepository.php index d899bcf..88dabce 100644 --- a/app/Text/TextRepository.php +++ b/app/Text/TextRepository.php @@ -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; } diff --git a/tests/Fakes/FakeTextRepository.php b/tests/Fakes/FakeTextRepository.php index 3e556a2..35ee9f4 100644 --- a/tests/Fakes/FakeTextRepository.php +++ b/tests/Fakes/FakeTextRepository.php @@ -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 { @@ -61,4 +62,29 @@ class FakeTextRepository implements TextRepository 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) + ); + } } From 051e44033f2a1a9387c9c58a7b133e7f8d23c042 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:43:48 +0300 Subject: [PATCH 10/23] wire user texts routes and update seed open POST /api/texts and node create endpoints to any authenticated user; expose new /texts and /texts/{id} pages plus admin-only GET /api/texts/all. ViewController gains userTexts and userText methods. seed gives Tanach to the regular user and adds a second non-admin user. --- app/View/ViewController.php | 20 ++++++++++++++++++++ bootstrap/app.php | 17 ++++++++++------- data/seedDb.php | 8 ++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/app/View/ViewController.php b/app/View/ViewController.php index efe322b..aabf223 100644 --- a/app/View/ViewController.php +++ b/app/View/ViewController.php @@ -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); diff --git a/bootstrap/app.php b/bootstrap/app.php index b4e9907..ce4d204 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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/texts/all', [TextController::class, 'getAllTexts']); })->add(AdminMiddleware::class)->add(AuthMiddleware::class); return $app; diff --git a/data/seedDb.php b/data/seedDb.php index aaa329f..21c48cc 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -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 = []; From e56cb56ce77fa6f91480c082e37e1931acffebb1 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:45:15 +0300 Subject: [PATCH 11/23] test node controller ownership checks add failing tests asserting 403 when a non-owner tries to read or write nodes on another user's text, plus admin bypass. existing tests now attach a session user to mirror the new controller signature. --- .../BulkCreateNodesControllerTest.php | 66 +++++- tests/e2e/Controllers/NodeControllerTest.php | 194 +++++++++++++++--- 2 files changed, 222 insertions(+), 38 deletions(-) diff --git a/tests/e2e/Controllers/BulkCreateNodesControllerTest.php b/tests/e2e/Controllers/BulkCreateNodesControllerTest.php index 660217e..2fac7e8 100644 --- a/tests/e2e/Controllers/BulkCreateNodesControllerTest.php +++ b/tests/e2e/Controllers/BulkCreateNodesControllerTest.php @@ -7,6 +7,7 @@ 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; @@ -23,19 +24,32 @@ 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(); - $user = $userRepo->create(new CreateUserDto( + $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', - user: $user, + user: $this->user, )); $this->nodeRepo = new FakeNodeRepository(); @@ -54,13 +68,17 @@ class BulkCreateNodesControllerTest extends TestCase ); } - private function makeRequest(array $data): ServerRequestInterface - { + private function makeRequest( + array $data, + ?User $user = null, + ): 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); + $sessionUser = $user ?? $this->user; + return $request->withAttribute('user', $sessionUser); } public function test_bulk_create_nodes_returns_201_with_created_nodes(): void @@ -227,4 +245,42 @@ class BulkCreateNodesControllerTest extends TestCase $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()); + } } diff --git a/tests/e2e/Controllers/NodeControllerTest.php b/tests/e2e/Controllers/NodeControllerTest.php index c99b609..9879551 100644 --- a/tests/e2e/Controllers/NodeControllerTest.php +++ b/tests/e2e/Controllers/NodeControllerTest.php @@ -7,8 +7,10 @@ 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; @@ -21,21 +23,65 @@ 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(); - $user = $userRepo->create(new CreateUserDto( + $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', user: $user)); + $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 @@ -47,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( @@ -60,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()); @@ -68,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); @@ -87,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']); @@ -102,15 +193,14 @@ class NodeControllerTest extends TestCase parentNode: null, )); - $body = new StreamFactory()->createStream(json_encode([ - 'textId' => 0, - 'title' => 'Child Node', - 'parentNodeId' => $rootNode->getId(), - ])); - $request = new ServerRequestFactory() - ->createServerRequest('POST', 'http://localhost/api/nodes') - ->withHeader('Content-Type', 'application/json') - ->withBody($body); + $request = $this->makeRequest( + [ + 'textId' => 0, + 'title' => 'Child Node', + 'parentNodeId' => $rootNode->getId(), + ], + $this->user, + ); $response = $this->controller->createNode( $request, @@ -127,14 +217,13 @@ class NodeControllerTest extends TestCase public function test_create_node_returns_400_when_title_missing(): void { - $body = new StreamFactory()->createStream(json_encode([ - 'textId' => 0, - 'parentNodeId' => null, - ])); - $request = new ServerRequestFactory() - ->createServerRequest('POST', 'http://localhost/api/nodes') - ->withHeader('Content-Type', 'application/json') - ->withBody($body); + $request = $this->makeRequest( + [ + 'textId' => 0, + 'parentNodeId' => null, + ], + $this->user, + ); $response = $this->controller->createNode( $request, @@ -147,15 +236,14 @@ class NodeControllerTest extends TestCase public function test_create_node_returns_404_when_text_not_found(): void { - $body = new StreamFactory()->createStream(json_encode([ - 'textId' => 99, - 'title' => 'Some Node', - 'parentNodeId' => null, - ])); - $request = new ServerRequestFactory() - ->createServerRequest('POST', 'http://localhost/api/nodes') - ->withHeader('Content-Type', 'application/json') - ->withBody($body); + $request = $this->makeRequest( + [ + 'textId' => 99, + 'title' => 'Some Node', + 'parentNodeId' => null, + ], + $this->user, + ); $response = $this->controller->createNode( $request, @@ -165,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()); + } } From 7473af4163afdb91e5dca359dafa40c7e28aef60 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:45:47 +0300 Subject: [PATCH 12/23] enforce text ownership on node endpoints getNodesOfText, createNode, and bulkCreateNodes now require the session user, look up the target text, and respond 403 unless the user owns the text or is an admin. paves the way for moving these endpoints out of the admin-only group. --- app/Node/NodeController.php | 106 +++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/app/Node/NodeController.php b/app/Node/NodeController.php index bb3aa30..6e39a8f 100644 --- a/app/Node/NodeController.php +++ b/app/Node/NodeController.php @@ -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'); + } } From 6d11f7e8873b697a9739af606bf329af6e4ac7b0 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:46:41 +0300 Subject: [PATCH 13/23] add user texts and text detail pages new /texts page lets a user manage their own texts (list + create form linking to /texts/{id}); /texts/{id} reuses text.js for the node tree, with a back link to /texts. home gains a 'My texts' link in the header. the admin texts page now sources its cross-user list from /api/texts/all. --- public/js/texts.js | 9 ++++---- public/js/userTexts.js | 43 +++++++++++++++++++++++++++++++++++ views/templates/home.php | 3 +++ views/templates/userText.php | 22 ++++++++++++++++++ views/templates/userTexts.php | 38 +++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 public/js/userTexts.js create mode 100644 views/templates/userText.php create mode 100644 views/templates/userTexts.php diff --git a/public/js/texts.js b/public/js/texts.js index a0b69e1..6fca9fd 100644 --- a/public/js/texts.js +++ b/public/js/texts.js @@ -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/texts/all', { credentials: 'same-origin', }); const texts = await res.json(); - textsList.innerHTML = texts.map(text => - '
  • ' + text.name - + '
  • ').join(''); + + ''; + }).join(''); } form.addEventListener('submit', async (e) => { diff --git a/public/js/userTexts.js b/public/js/userTexts.js new file mode 100644 index 0000000..db1503c --- /dev/null +++ b/public/js/userTexts.js @@ -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 '
  • ' + + text.name + + '
  • '; + }).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(); +}); diff --git a/views/templates/home.php b/views/templates/home.php index 97d3aa7..63cfbfd 100644 --- a/views/templates/home.php +++ b/views/templates/home.php @@ -11,6 +11,9 @@

    Home

    + + My texts + Today's schedule diff --git a/views/templates/userText.php b/views/templates/userText.php new file mode 100644 index 0000000..c9b568a --- /dev/null +++ b/views/templates/userText.php @@ -0,0 +1,22 @@ + + + + + + Daily Goals - Text + + + + +
    +
    +
    + + + diff --git a/views/templates/userTexts.php b/views/templates/userTexts.php new file mode 100644 index 0000000..a90ca94 --- /dev/null +++ b/views/templates/userTexts.php @@ -0,0 +1,38 @@ + + + + + + Daily Goals - My Texts + + + + +
    +
      +
      + +
      + +
      +
      +
      + + + + From 71e5fb8fda50d4cde57ee5573b3fae68e31dd27d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:47:20 +0300 Subject: [PATCH 14/23] add cypress coverage for user text pages loginAsSecondUser helper backs new specs that cover the /texts list (own-only scoping, create form, link to /texts/{id}) and /texts/{id} detail (own access, 403 on another user's text, owner can add a child node). --- cypress/e2e/userText.cy.js | 57 +++++++++++++++++++++++++++++++++++++ cypress/e2e/userTexts.cy.js | 53 ++++++++++++++++++++++++++++++++++ cypress/support/commands.js | 4 +++ 3 files changed, 114 insertions(+) create mode 100644 cypress/e2e/userText.cy.js create mode 100644 cypress/e2e/userTexts.cy.js diff --git a/cypress/e2e/userText.cy.js b/cypress/e2e/userText.cy.js new file mode 100644 index 0000000..a633e5a --- /dev/null +++ b/cypress/e2e/userText.cy.js @@ -0,0 +1,57 @@ +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 li').first().within(() => { + cy.get('button.add-child').click() + cy.get('input.child-title').type('My new child') + cy.get('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) + }) + }) +}) diff --git a/cypress/e2e/userTexts.cy.js b/cypress/e2e/userTexts.cy.js new file mode 100644 index 0000000..c1ad25c --- /dev/null +++ b/cypress/e2e/userTexts.cy.js @@ -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') + }) +}) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 61e6549..1c1d028 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -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') +}) From c065e065e9d5570557c572a6121fc5cc7c138dd3 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:54:43 +0300 Subject: [PATCH 15/23] fix admin texts route shadow conflict FastRoute rejected /api/texts/all because the previously declared variable route /api/texts/{textId} would shadow it, crashing the app on boot. move the admin all-texts endpoint to /api/admin/texts to clear the conflict; admin texts.js follows the new URL. --- bootstrap/app.php | 2 +- public/js/texts.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bootstrap/app.php b/bootstrap/app.php index ce4d204..1e4d6cf 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -69,7 +69,7 @@ $app->group('', function (RouteCollectorProxy $group) { [ViewController::class, 'text'] ); - $group->get('/api/texts/all', [TextController::class, 'getAllTexts']); + $group->get('/api/admin/texts', [TextController::class, 'getAllTexts']); })->add(AdminMiddleware::class)->add(AuthMiddleware::class); return $app; diff --git a/public/js/texts.js b/public/js/texts.js index 6fca9fd..a4736ec 100644 --- a/public/js/texts.js +++ b/public/js/texts.js @@ -3,7 +3,7 @@ document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('texts-form'); async function loadTexts() { - const res = await fetch('/api/texts/all', { + const res = await fetch('/api/admin/texts', { credentials: 'same-origin', }); const texts = await res.json(); From 3a1e91cc4fb8fcd41233a914ee765f5769f5fd41 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 21:58:34 +0300 Subject: [PATCH 16/23] scope user text child-add cypress selectors the seeded text already has nested nodes, so 'li.first()' matched multiple buttons. scope the selectors to top-level li children to match the working pattern in adminText.cy.js. --- cypress/e2e/userText.cy.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cypress/e2e/userText.cy.js b/cypress/e2e/userText.cy.js index a633e5a..f26cc61 100644 --- a/cypress/e2e/userText.cy.js +++ b/cypress/e2e/userText.cy.js @@ -30,11 +30,12 @@ describe('The user text detail page', () => { cy.visit('/texts/0') cy.wait('@getNodes') - cy.get('#text-detail li').first().within(() => { - cy.get('button.add-child').click() - cy.get('input.child-title').type('My new child') - cy.get('button.save-child').click() - }) + 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') }) From db93871194df0de4224d03bd94008e0b0c6ce769 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 22:05:58 +0300 Subject: [PATCH 17/23] move inline use statements to file headers two type hints introduced earlier on this branch referenced classes by their fully-qualified names inline. hoist them to the top-of-file use block per backend-context.md PHP rules. --- tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php | 3 ++- tests/e2e/Controllers/TextControllerTest.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php b/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php index 5a2afc6..ff38c45 100644 --- a/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php +++ b/tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php @@ -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,7 +30,7 @@ class GetTodaysScheduleTest extends TestCase private GetTodaysSchedule $useCase; - private \App\User\User $user; + private User $user; protected function setUp(): void { diff --git a/tests/e2e/Controllers/TextControllerTest.php b/tests/e2e/Controllers/TextControllerTest.php index 6498568..560081b 100644 --- a/tests/e2e/Controllers/TextControllerTest.php +++ b/tests/e2e/Controllers/TextControllerTest.php @@ -9,6 +9,7 @@ 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; @@ -55,7 +56,7 @@ class TextControllerTest extends TestCase $this->controller = new TextController($this->textRepo); } - private function makeRequest(?User $user): \Psr\Http\Message\ServerRequestInterface + private function makeRequest(?User $user): ServerRequestInterface { $request = new ServerRequestFactory() ->createServerRequest('GET', 'http://localhost/texts'); From b07b1e26665e1f239c5490358dc1789a8f0fdeca Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 22:14:54 +0300 Subject: [PATCH 18/23] add session start protocol and pre-commit checklist AGENTS.md gains a non-negotiable session start protocol that forces reading the context files and checking the current branch before any edits. shared.md gains a pre-commit checklist covering branch/scope, code rules, mechanical checks, and commit metadata. both additions exist because this branch's history shows what happens when the rules are treated as background information rather than active checklists. --- AGENTS.md | 18 ++++++++++++++++++ ai/shared.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5dea88b..73e2226 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,21 @@ Read these on every session. Rules in them override defaults. @ai/shared.md @ai/backend-context.md @ai/frontend-context.md + +## Session start protocol + +Before responding to the first user message in a session, you MUST: + +1. Read `ai/shared.md`, `ai/backend-context.md`, `ai/frontend-context.md` in + full. Do not skim. Do not skip on the assumption they were read in a + prior session - context is not preserved. +2. Run `git status` and `git branch --show-current`. If on `master` or + `main`, do NOT make any edits until a feature branch exists, even if + the user's first message looks like a quick read-only question. Many + "quick questions" turn into edits. +3. Confirm in your first response that the rules were read and the branch + was checked. Do not narrate the contents - just acknowledge. + +Skipping this protocol caused real bugs and rework in past sessions +(work landed on master, TDD order was lost, formatter not run, banned +constructs slipped in). Treat the protocol as non-negotiable. diff --git a/ai/shared.md b/ai/shared.md index cc85dd1..fb37c12 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -72,3 +72,39 @@ guides (`backend-context.md`, `frontend-context.md`) extend these. - NEVER work directly on master/main - always create and work on a branch Do not push anything. Make commits as you go. + +## Pre-commit checklist + +Before EVERY commit (no exceptions), verify each item. Treat this as +mechanical, not aspirational - a "yes" to all is required. + +**Branch + scope:** +- [ ] On a feature branch (not master/main). +- [ ] This commit is one logical change. If it spans unrelated changes, + stop and split it. +- [ ] Tests for new behavior were committed BEFORE this implementation + (or this commit IS the failing-test commit). + +**Code rules** (see `backend-context.md` PHP rules, +`frontend-context.md` JS rules): +- [ ] No arrow functions (`fn () =>`). +- [ ] No inline FQCNs in type hints, return types, or `::class` + references (`\App\Foo\Bar` -> hoist to `use App\Foo\Bar;`). +- [ ] No default parameter values on methods/functions/constructors. +- [ ] Find/lookup repository methods return new instances, not stored + references. +- [ ] No em dashes (use hyphens). +- [ ] Variable names are explicit (no `$t`, `$n`, `$res`, etc.). + +**Mechanical checks:** +- [ ] `php-cs-fixer fix --config=.php-cs-fixer.dist.php ` + 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. From 5d6c9f7ec95d721db72fb2d930f155217ab7a035 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 2 May 2026 22:15:23 +0300 Subject: [PATCH 19/23] add llm anti-patterns to context files name the LLM-default constructs this project forbids in explicit before/after tables. catching the trap by pattern match is more reliable than expecting a general rule to be applied at write time. backend table covers PHP traps (arrow fns, inline FQCNs, default params, stored refs, em dashes, short names); frontend table covers JS/template/ cypress traps. --- ai/backend-context.md | 19 +++++++++++++++++++ ai/frontend-context.md | 15 +++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/ai/backend-context.md b/ai/backend-context.md index fca41f0..e59b9a6 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -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. diff --git a/ai/frontend-context.md b/ai/frontend-context.md index 8757e1f..5e279aa 100644 --- a/ai/frontend-context.md +++ b/ai/frontend-context.md @@ -31,3 +31,18 @@ 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 `` in a `.php` template | put logic in `public/js/.js`, load via `` in a `.php` template | put logic in `public/js/.js`, load via `