test schedule rescheduling

This commit is contained in:
Yisroel Baum 2026-08-19 22:41:03 +03:00
parent a06bb6f003
commit 258e4ea27a
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
2 changed files with 338 additions and 0 deletions

View file

@ -558,6 +558,125 @@ class ScheduleEndpointTest extends TestCase
->assertJsonCount(2, 'assignments');
}
public function test_it_reschedules_only_unfinished_assignments(): void
{
$user = $this->createUser('reader@example.com');
$set = $this->createSet($user, 'Course');
$lessonLevel = $this->createLevel($set, 'lesson');
$repository = app(ElementRepository::class);
foreach (range(1, 4) as $number) {
$repository->create(new CreateElementDto(
name: "Lesson {$number}",
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-10',
'targetDate' => '2026-08-13',
])->assertCreated();
$this->credentialedPatch('/api/assignments/1', [
'completed' => true,
])->assertOk();
$response = $this->credentialedPatch('/api/schedules/1', [
'startDate' => '2026-08-20',
'targetDate' => '2026-08-21',
'workloadPlacement' => 'end',
]);
$response->assertOk()
->assertJsonPath('schedule.startDate', '2026-08-20')
->assertJsonPath('schedule.targetDate', '2026-08-21')
->assertJsonPath('schedule.days.0.date', '2026-08-10')
->assertJsonPath(
'schedule.days.0.assignments.0.completedAt',
'2026-08-15T12:30:45+00:00',
)
->assertJsonPath('schedule.days.1.date', '2026-08-20')
->assertJsonCount(1, 'schedule.days.1.assignments')
->assertJsonPath('schedule.days.1.assignments.0.id', 2)
->assertJsonPath('schedule.days.2.date', '2026-08-21')
->assertJsonCount(2, 'schedule.days.2.assignments')
->assertJsonPath('schedule.days.2.assignments.0.id', 3)
->assertJsonPath('schedule.days.2.assignments.1.id', 4);
$this->assertDatabaseHas('schedules', [
'id' => 1,
'start_date' => '2026-08-20',
'target_date' => '2026-08-21',
]);
$this->assertDatabaseHas('schedule_assignments', [
'id' => 1,
'scheduled_date' => '2026-08-10',
'completed_at' => '2026-08-15 12:30:45',
]);
$this->assertDatabaseHas('schedule_assignments', [
'id' => 2,
'scheduled_date' => '2026-08-20',
'completed_at' => null,
]);
$this->assertDatabaseHas('schedule_assignments', [
'id' => 3,
'scheduled_date' => '2026-08-21',
'completed_at' => null,
]);
$this->assertDatabaseHas('schedule_assignments', [
'id' => 4,
'scheduled_date' => '2026-08-21',
'completed_at' => null,
]);
}
public function test_it_hides_another_users_schedule_when_rescheduling(): 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-10',
'targetDate' => '2026-08-10',
])->assertCreated();
$this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'viewer-token',
)->patchJson('/api/schedules/1', [
'startDate' => '2026-08-20',
'targetDate' => '2026-08-21',
'workloadPlacement' => 'middle',
])->assertNotFound()->assertExactJson([
'error' => 'schedule not found',
]);
$this->assertDatabaseHas('schedules', [
'id' => 1,
'start_date' => '2026-08-10',
'target_date' => '2026-08-10',
]);
}
public function test_it_rejects_invalid_assignment_completion_input(): void
{
$user = $this->createUser('reader@example.com');
@ -718,6 +837,7 @@ class ScheduleEndpointTest extends TestCase
])->assertStatus(401);
$this->getJson('/api/schedules')->assertStatus(401);
$this->getJson('/api/schedules/1')->assertStatus(401);
$this->patchJson('/api/schedules/1', [])->assertStatus(401);
$this->postJson('/api/schedules', [])->assertStatus(401);
}

View file

@ -0,0 +1,218 @@
<?php
namespace Tests\Unit\Schedule\UseCases;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\CreateScheduleAssignmentDto;
use App\Schedule\CreateScheduleDto;
use App\Schedule\EvenDistributionScheduler;
use App\Schedule\ScheduleAssignment;
use App\Schedule\UseCases\RescheduleSchedule\RescheduleSchedule;
use App\Schedule\UseCases\RescheduleSchedule\RescheduleScheduleRequest;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeScheduleRepository;
class RescheduleScheduleTest extends TestCase
{
public function test_it_redistributes_only_unfinished_assignments(): void
{
$user = $this->user(1, 'reader@example.com');
$repository = new FakeScheduleRepository;
$schedule = $repository->create($this->schedule($user));
$assignments = $schedule->getAssignments();
$firstCompletion = $this->utc('2026-08-15T10:00:00');
$secondCompletion = $this->utc('2026-08-16T11:00:00');
$assignments[1]->complete($firstCompletion);
$assignments[3]->complete($secondCompletion);
$repository->updateAssignment($assignments[1]);
$repository->updateAssignment($assignments[3]);
$rescheduled = $this->useCase($repository)->execute(
new RescheduleScheduleRequest(
scheduleId: $schedule->getId(),
user: $user,
startDate: '2026-08-20',
targetDate: '2026-08-21',
workloadPlacement: 'end',
),
);
$this->assertSame('2026-08-20', $rescheduled->getStartDate()
->format('Y-m-d'));
$this->assertSame('2026-08-21', $rescheduled->getTargetDate()
->format('Y-m-d'));
$this->assertSame(
[
'2026-08-20',
'2026-08-11',
'2026-08-21',
'2026-08-13',
'2026-08-21',
],
array_map(function (ScheduleAssignment $assignment): string {
return $assignment->getScheduledDate()->format('Y-m-d');
}, $rescheduled->getAssignments()),
);
$this->assertSame(
[null, $firstCompletion, null, $secondCompletion, null],
array_map(function (
ScheduleAssignment $assignment,
): ?DateTimeImmutable {
return $assignment->getCompletedAt();
}, $rescheduled->getAssignments()),
);
$this->assertSame(
[1, 2, 3, 4, 5],
array_map(function (ScheduleAssignment $assignment): int {
return $assignment->getId();
}, $rescheduled->getAssignments()),
);
$this->assertSame(
['Lesson 1', 'Lesson 2', 'Lesson 3', 'Lesson 4', 'Lesson 5'],
array_map(function (ScheduleAssignment $assignment): string {
return $assignment->getName();
}, $rescheduled->getAssignments()),
);
}
public function test_it_rejects_a_target_before_the_start(): void
{
$user = $this->user(1, 'reader@example.com');
$repository = new FakeScheduleRepository;
$schedule = $repository->create($this->schedule($user));
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage(
'targetDate must not be before startDate',
);
$this->useCase($repository)->execute(
new RescheduleScheduleRequest(
scheduleId: $schedule->getId(),
user: $user,
startDate: '2026-08-21',
targetDate: '2026-08-20',
workloadPlacement: 'middle',
),
);
}
public function test_it_rejects_an_unknown_workload_placement(): void
{
$user = $this->user(1, 'reader@example.com');
$repository = new FakeScheduleRepository;
$schedule = $repository->create($this->schedule($user));
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage(
'workloadPlacement must be start, middle, or end',
);
$this->useCase($repository)->execute(
new RescheduleScheduleRequest(
scheduleId: $schedule->getId(),
user: $user,
startDate: '2026-08-20',
targetDate: '2026-08-21',
workloadPlacement: 'sideways',
),
);
}
public function test_it_rejects_a_schedule_without_unfinished_work(): void
{
$user = $this->user(1, 'reader@example.com');
$repository = new FakeScheduleRepository;
$schedule = $repository->create($this->schedule($user));
foreach ($schedule->getAssignments() as $assignment) {
$assignment->complete($this->utc('2026-08-15T10:00:00'));
$repository->updateAssignment($assignment);
}
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage(
'schedule has no unfinished assignments',
);
$this->useCase($repository)->execute(
new RescheduleScheduleRequest(
scheduleId: $schedule->getId(),
user: $user,
startDate: '2026-08-20',
targetDate: '2026-08-21',
workloadPlacement: 'middle',
),
);
}
public function test_it_hides_another_users_schedule(): void
{
$repository = new FakeScheduleRepository;
$owner = $this->user(1, 'owner@example.com');
$schedule = $repository->create($this->schedule($owner));
$this->expectException(NotFoundException::class);
$this->expectExceptionMessage('schedule not found');
$this->useCase($repository)->execute(
new RescheduleScheduleRequest(
scheduleId: $schedule->getId(),
user: $this->user(2, 'viewer@example.com'),
startDate: '2026-08-20',
targetDate: '2026-08-21',
workloadPlacement: 'middle',
),
);
}
private function useCase(
FakeScheduleRepository $repository,
): RescheduleSchedule {
return new RescheduleSchedule(
$repository,
new EvenDistributionScheduler,
);
}
private function schedule(User $user): CreateScheduleDto
{
$assignments = [];
foreach (range(1, 5) as $number) {
$assignments[] = new CreateScheduleAssignmentDto(
name: "Lesson {$number}",
kind: 'lesson',
path: ['Course', "Lesson {$number}"],
scheduledDate: $this->utc("2026-08-1{$number}"),
position: $number,
);
}
return new CreateScheduleDto(
user: $user,
setName: 'Course',
elementKind: 'lesson',
startDate: $this->utc('2026-08-10'),
targetDate: $this->utc('2026-08-15'),
assignments: $assignments,
);
}
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'));
}
}