diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index a0e9cf8..b68e2ad 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Schedule; use App\Auth\CreateSessionDto; +use App\Auth\Clock; use App\Auth\SessionRepository; use App\Element\CreateElementDto; use App\Element\ElementModel; @@ -25,6 +26,7 @@ use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Testing\TestResponse; use Tests\TestCase; +use Tests\Fakes\FakeClock; class ScheduleEndpointTest extends TestCase { @@ -94,6 +96,7 @@ class ScheduleEndpointTest extends TestCase 'assignments' => [ [ 'id' => 1, + 'completedAt' => null, 'element' => [ 'name' => 'Chapter 1', 'kind' => 'chapter', @@ -115,6 +118,7 @@ class ScheduleEndpointTest extends TestCase 'assignments' => [ [ 'id' => 2, + 'completedAt' => null, 'element' => [ 'name' => 'Chapter 1', 'kind' => 'chapter', @@ -351,6 +355,130 @@ class ScheduleEndpointTest extends TestCase ->assertExactJson(['error' => 'date is required']); } + public function test_it_completes_and_reopens_an_assignment(): void + { + $user = $this->createUser('reader@example.com'); + $set = $this->createSet($user, 'Course'); + $lessonLevel = $this->createLevel($set, 'lesson'); + $repository = app(ElementRepository::class); + $repository->create(new CreateElementDto( + name: 'First lesson', + level: $lessonLevel, + parentElement: null, + )); + $repository->create(new CreateElementDto( + name: 'Second lesson', + level: $lessonLevel, + parentElement: null, + )); + $this->createSession($user, 'valid-token'); + $completedAt = new DateTimeImmutable( + '2026-08-15T12:30:45', + new DateTimeZone('UTC'), + ); + $this->app->instance(Clock::class, new FakeClock($completedAt)); + $this->credentialedPost('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-08-20', + 'targetDate' => '2026-08-20', + ])->assertCreated(); + + $this->credentialedPatch('/api/assignments/1', [ + 'completed' => true, + ])->assertOk()->assertExactJson([ + 'assignment' => [ + 'id' => 1, + 'completedAt' => '2026-08-15T12:30:45+00:00', + ], + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 1, + 'completed_at' => '2026-08-15 12:30:45', + ]); + $this->credentialedGet('/api/schedules/1') + ->assertOk() + ->assertJsonPath( + 'schedule.days.0.assignments.0.completedAt', + '2026-08-15T12:30:45+00:00', + ) + ->assertJsonPath( + 'schedule.days.0.assignments.1.completedAt', + null, + ); + $this->credentialedGet('/api/assignments?date=2026-08-20') + ->assertOk() + ->assertJsonCount(1, 'assignments') + ->assertJsonPath('assignments.0.id', 2); + + $this->credentialedPatch('/api/assignments/1', [ + 'completed' => false, + ])->assertOk()->assertExactJson([ + 'assignment' => [ + 'id' => 1, + 'completedAt' => null, + ], + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 1, + 'completed_at' => null, + ]); + $this->credentialedGet('/api/assignments?date=2026-08-20') + ->assertOk() + ->assertJsonCount(2, 'assignments'); + } + + public function test_it_rejects_invalid_assignment_completion_input(): void + { + $user = $this->createUser('reader@example.com'); + $this->createSession($user, 'valid-token'); + + $this->credentialedPatch('/api/assignments/1', [ + 'completed' => 'yes', + ])->assertBadRequest()->assertExactJson([ + 'error' => 'completed must be a boolean', + ]); + } + + public function test_it_hides_another_users_assignment(): void + { + $owner = $this->createUser('owner@example.com'); + $viewer = $this->createUser('viewer@example.com'); + $set = $this->createSet($owner, 'Course'); + $lessonLevel = $this->createLevel($set, 'lesson'); + app(ElementRepository::class)->create(new CreateElementDto( + name: 'Welcome', + level: $lessonLevel, + parentElement: null, + )); + $this->createSession($owner, 'owner-token'); + $this->createSession($viewer, 'viewer-token'); + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'owner-token', + )->postJson('/api/schedules', [ + 'setId' => $set->getId(), + 'levelId' => $lessonLevel->getId(), + 'startDate' => '2026-08-15', + 'targetDate' => '2026-08-15', + ])->assertCreated(); + + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'viewer-token', + )->patchJson('/api/assignments/1', [ + 'completed' => true, + ])->assertNotFound()->assertExactJson([ + 'error' => 'assignment not found', + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 1, + 'completed_at' => null, + ]); + } + public function test_it_is_stable_after_sources_change_or_are_deleted(): void { $user = $this->createUser('reader@example.com'); @@ -455,6 +583,9 @@ class ScheduleEndpointTest extends TestCase { $this->getJson('/api/assignments?date=2026-08-15') ->assertStatus(401); + $this->patchJson('/api/assignments/1', [ + 'completed' => true, + ])->assertStatus(401); $this->getJson('/api/schedules')->assertStatus(401); $this->getJson('/api/schedules/1')->assertStatus(401); $this->postJson('/api/schedules', [])->assertStatus(401); @@ -520,4 +651,18 @@ class ScheduleEndpointTest extends TestCase 'valid-token', )->getJson($uri); } + + /** + * @param array $payload + */ + private function credentialedPatch( + string $uri, + array $payload, + ): TestResponse { + return $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->patchJson($uri, $payload); + } } diff --git a/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php b/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php new file mode 100644 index 0000000..12f86f5 --- /dev/null +++ b/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php @@ -0,0 +1,49 @@ +assignment(); + $firstCompletion = $this->utc('2026-08-15T12:00:00'); + $laterCompletion = $this->utc('2026-08-15T13:00:00'); + + $assignment->complete($firstCompletion); + $assignment->complete($laterCompletion); + + $this->assertSame($firstCompletion, $assignment->getCompletedAt()); + + $assignment->reopen(); + + $this->assertNull($assignment->getCompletedAt()); + + $assignment->complete($laterCompletion); + + $this->assertSame($laterCompletion, $assignment->getCompletedAt()); + } + + private function assignment(): ScheduleAssignment + { + return new ScheduleAssignment( + id: 1, + name: 'Lesson', + kind: 'lesson', + path: ['Course', 'Lesson'], + scheduledDate: $this->utc('2026-08-15'), + position: 1, + completedAt: null, + ); + } + + private function utc(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php index 1d1e3fd..c6bc95f 100644 --- a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php +++ b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php @@ -22,7 +22,7 @@ class ListAssignmentsForDateTest extends TestCase $user = $this->user(1, 'reader@example.com'); $otherUser = $this->user(2, 'other@example.com'); $repository = new FakeScheduleRepository; - $repository->create($this->schedule( + $olderSchedule = $repository->create($this->schedule( user: $user, setName: 'Older plan', date: '2026-08-15', @@ -46,6 +46,12 @@ class ListAssignmentsForDateTest extends TestCase date: '2026-08-16', assignmentNames: ['Later'], )); + $completedAssignment = $olderSchedule->getAssignments()[0]; + $completedAssignment->complete(new DateTimeImmutable( + '2026-08-15T12:00:00', + new DateTimeZone('UTC'), + )); + $repository->updateAssignment($completedAssignment); $assignments = (new ListAssignmentsForDate($repository))->execute( new ListAssignmentsForDateRequest( @@ -55,13 +61,13 @@ class ListAssignmentsForDateTest extends TestCase ); $this->assertSame( - ['Newer plan', 'Older plan', 'Older plan'], + ['Newer plan', 'Older plan'], array_map(function ($assignment): string { return $assignment->getSetName(); }, $assignments), ); $this->assertSame( - ['Third', 'First', 'Second'], + ['Third', 'Second'], array_map(function ($assignment): string { return $assignment->getAssignment()->getName(); }, $assignments), diff --git a/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php b/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php new file mode 100644 index 0000000..8950edc --- /dev/null +++ b/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php @@ -0,0 +1,175 @@ +user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + $now = $this->utc('2026-08-15T12:00:00'); + + $assignment = (new SetAssignmentCompletion( + $repository, + new FakeClock($now), + ))->execute(new SetAssignmentCompletionRequest( + assignmentId: $schedule->getAssignments()[0]->getId(), + user: $user, + completed: true, + )); + + $this->assertSame($now, $assignment->getCompletedAt()); + $this->assertSame( + $now, + $repository->findAssignmentForUser( + $assignment->getId(), + $user, + )?->getCompletedAt(), + ); + } + + public function test_it_preserves_the_first_completion_time(): void + { + $user = $this->user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + $assignmentId = $schedule->getAssignments()[0]->getId(); + $firstCompletion = $this->utc('2026-08-15T12:00:00'); + $laterCompletion = $this->utc('2026-08-15T13:00:00'); + + (new SetAssignmentCompletion( + $repository, + new FakeClock($firstCompletion), + ))->execute(new SetAssignmentCompletionRequest( + assignmentId: $assignmentId, + user: $user, + completed: true, + )); + $assignment = (new SetAssignmentCompletion( + $repository, + new FakeClock($laterCompletion), + ))->execute(new SetAssignmentCompletionRequest( + assignmentId: $assignmentId, + user: $user, + completed: true, + )); + + $this->assertSame( + $firstCompletion, + $assignment->getCompletedAt(), + ); + } + + public function test_it_reopens_a_completed_assignment(): void + { + $user = $this->user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + $assignmentId = $schedule->getAssignments()[0]->getId(); + $useCase = new SetAssignmentCompletion( + $repository, + new FakeClock($this->utc('2026-08-15T12:00:00')), + ); + $useCase->execute(new SetAssignmentCompletionRequest( + assignmentId: $assignmentId, + user: $user, + completed: true, + )); + + $assignment = $useCase->execute( + new SetAssignmentCompletionRequest( + assignmentId: $assignmentId, + user: $user, + completed: false, + ), + ); + + $this->assertNull($assignment->getCompletedAt()); + } + + public function test_it_hides_another_users_assignment(): void + { + $repository = new FakeScheduleRepository; + $owner = $this->user(1, 'owner@example.com'); + $schedule = $repository->create($this->schedule($owner)); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('assignment not found'); + + (new SetAssignmentCompletion( + $repository, + new FakeClock($this->utc('2026-08-15T12:00:00')), + ))->execute(new SetAssignmentCompletionRequest( + assignmentId: $schedule->getAssignments()[0]->getId(), + user: $this->user(2, 'viewer@example.com'), + completed: true, + )); + } + + public function test_it_requires_a_boolean_completion_value(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('completed must be a boolean'); + + (new SetAssignmentCompletion( + new FakeScheduleRepository, + new FakeClock($this->utc('2026-08-15T12:00:00')), + ))->execute(new SetAssignmentCompletionRequest( + assignmentId: 1, + user: $this->user(1, 'reader@example.com'), + completed: null, + )); + } + + private function schedule(User $user): CreateScheduleDto + { + $scheduledDate = $this->utc('2026-08-20'); + + return new CreateScheduleDto( + user: $user, + setName: 'Course', + elementKind: 'lesson', + startDate: $scheduledDate, + targetDate: $scheduledDate, + assignments: [ + new CreateScheduleAssignmentDto( + name: 'Lesson', + kind: 'lesson', + path: ['Course', 'Lesson'], + scheduledDate: $scheduledDate, + position: 1, + ), + ], + ); + } + + private function user(int $id, string $email): User + { + return new User( + id: $id, + email: new EmailAddress($email), + passwordHash: 'hashed-password', + ); + } + + private function utc(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } +}