using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class TimetableApiTests(AppHostFixture fixture)
{
private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
private static readonly DateTime SaturdayMorning = new(2012, 4, 7, 10, 20, 0, DateTimeKind.Utc);
[Fact]
public async Task GetTimetable_UnknownSchool_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
using var response = await client.GetAsync("/api/schools/999999/timetable", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("unknown-school", await ProblemCodeAsync(response));
}
[Fact]
public async Task HireMathematics_PlacesLessons_UnassignUncoversThem()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание наём", TuesdayMorning);
var teacher = await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
Assert.Equal(5, table.WeekDays);
Assert.Equal(7, table.LessonCount);
Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics" && lesson.TeacherId == teacher);
Assert.DoesNotContain(table.Uncovered, row => row.Subject == "Mathematics");
Assert.NotEmpty(table.Classes);
Assert.Contains(table.Rooms, room => room.Id == "classroom-101");
Assert.Contains(table.Lessons, lesson => lesson.RoomLabel.Length > 0);
using var unassign = await client.DeleteAsync(
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher)}/subjects/Mathematics",
TestContext.Current.CancellationToken);
unassign.EnsureSuccessStatusCode();
var after = await GetTimetableAsync(client, school.Id);
Assert.DoesNotContain(after.Lessons, lesson => lesson.Subject == "Mathematics");
Assert.Contains(after.Uncovered, row => row.Subject == "Mathematics");
}
[Fact]
public async Task PinThenHireAnother_KeepsTheLockedSlot()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание закрепление", TuesdayMorning);
var first = await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
var pinned = table.Lessons.First(lesson => lesson.Subject == "Mathematics");
using var pin = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/timetable/pin",
new { pinned.ClassId, pinned.Subject, pinned.RoomId, pinned.Day, pinned.Period },
TestContext.Current.CancellationToken);
pin.EnsureSuccessStatusCode();
var staffing = await GetStaffingAsync(client, school.Id);
var secondApplicant = staffing.Applicants[0];
await HireAsync(client, school.Id, secondApplicant.Id, "Teacher");
await AssignAsync(client, school.Id, secondApplicant.Id, "Mathematics");
var after = await GetTimetableAsync(client, school.Id);
Assert.Contains(
after.Lessons,
lesson =>
lesson.ClassId == pinned.ClassId
&& lesson.Subject == pinned.Subject
&& lesson.TeacherId == first
&& lesson.RoomId == pinned.RoomId
&& lesson.Day == pinned.Day
&& lesson.Period == pinned.Period
&& lesson.Locked);
using var gym = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/timetable/pin",
new { pinned.ClassId, subject = "Mathematics", roomId = "gym-hall", day = 0, period = 1 },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Conflict, gym.StatusCode);
Assert.Equal("pin-rejected", await ProblemCodeAsync(gym));
}
[Fact]
public async Task Reload_RestoresLockedLessons()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание диск", TuesdayMorning);
await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
var pinned = table.Lessons.First(lesson => lesson.Subject == "Mathematics");
using var pin = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/timetable/pin",
new { pinned.ClassId, pinned.Subject, pinned.RoomId, pinned.Day, pinned.Period },
TestContext.Current.CancellationToken);
pin.EnsureSuccessStatusCode();
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = await GetTimetableAsync(client, school.Id);
Assert.Contains(
restored.Lessons,
lesson =>
lesson.ClassId == pinned.ClassId
&& lesson.Subject == pinned.Subject
&& lesson.RoomId == pinned.RoomId
&& lesson.Day == pinned.Day
&& lesson.Period == pinned.Period
&& lesson.Locked);
}
///
/// The refusal codes the client switches on. `pin-rejected` is covered above; these five are
/// the rest of the documented contract, and none of them was exercised.
///
[Fact]
public async Task PinAndUnpinRefusals_CarryTheDocumentedCodes()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание отказы", TuesdayMorning);
var empty = await GetTimetableAsync(client, school.Id);
var klass = empty.Classes[0];
var room = empty.Rooms.First(candidate => candidate.Id.StartsWith("classroom-", StringComparison.Ordinal));
// Nobody teaches anything yet, so a well-formed pin still has no teacher to place.
Assert.Equal("no-teacher", await PinCodeAsync(client, school.Id, klass.Id, "Mathematics", room.Id));
Assert.Equal("unknown-class", await PinCodeAsync(client, school.Id, "cZZ", "Mathematics", room.Id));
Assert.Equal("unknown-subject", await PinCodeAsync(client, school.Id, klass.Id, "Astrology", room.Id));
Assert.Equal("unknown-room", await PinCodeAsync(client, school.Id, klass.Id, "Mathematics", "no-such-room"));
await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
var lesson = table.Lessons.First(row => row.Subject == "Mathematics");
using var pin = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/timetable/pin",
new { lesson.ClassId, lesson.Subject, lesson.RoomId, lesson.Day, lesson.Period },
TestContext.Current.CancellationToken);
pin.EnsureSuccessStatusCode();
var unpinQuery = $"classId={Uri.EscapeDataString(lesson.ClassId)}&subject={lesson.Subject}"
+ $"&day={lesson.Day}&period={lesson.Period}";
using var unpin = await client.DeleteAsync(
$"/api/schools/{school.Id}/timetable/pin?{unpinQuery}",
TestContext.Current.CancellationToken);
unpin.EnsureSuccessStatusCode();
// The lock is gone; asking again is a 404, not a silent success.
using var again = await client.DeleteAsync(
$"/api/schools/{school.Id}/timetable/pin?{unpinQuery}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, again.StatusCode);
Assert.Equal("unknown-lesson", await ProblemCodeAsync(again));
using var incomplete = await client.DeleteAsync(
$"/api/schools/{school.Id}/timetable/pin?classId={Uri.EscapeDataString(lesson.ClassId)}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, incomplete.StatusCode);
Assert.Equal("invalid-query", await ProblemCodeAsync(incomplete));
}
private static async Task PinCodeAsync(
HttpClient client,
int schoolId,
string classId,
string subject,
string roomId)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/timetable/pin",
new { classId, subject, roomId, day = 0, period = 1 },
TestContext.Current.CancellationToken);
Assert.False(response.IsSuccessStatusCode, $"pinning {subject} into {roomId} was expected to fail.");
return await ProblemCodeAsync(response);
}
[Fact]
public async Task GetTimetable_ClassAndPersonFilters_NarrowTheLessons()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание фильтр", TuesdayMorning);
var teacher = await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
var math = table.Lessons.First(lesson => lesson.Subject == "Mathematics");
var otherClass = table.Classes.First(klass => klass.Id != math.ClassId);
var byClass = await GetTimetableAsync(client, school.Id, classId: math.ClassId);
Assert.NotEmpty(byClass.Lessons);
Assert.All(byClass.Lessons, lesson => Assert.Equal(math.ClassId, lesson.ClassId));
Assert.Contains(byClass.Lessons, lesson => lesson.Subject == "Mathematics");
Assert.DoesNotContain(byClass.Lessons, lesson => lesson.ClassId == otherClass.Id);
var byTeacher = await GetTimetableAsync(client, school.Id, personId: teacher);
Assert.NotEmpty(byTeacher.Lessons);
Assert.All(byTeacher.Lessons, lesson => Assert.Equal(teacher, lesson.TeacherId));
}
[Fact]
public async Task Saturday_StillStoresTheWeekTable()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание суббота", SaturdayMorning);
await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics");
}
private static async Task HireMathAsync(HttpClient client, int schoolId)
{
var staffing = await GetStaffingAsync(client, schoolId);
var applicant = staffing.Applicants[0];
await HireAsync(client, schoolId, applicant.Id, "Teacher");
await AssignAsync(client, schoolId, applicant.Id, "Mathematics");
return applicant.Id;
}
private static async Task GetTimetableAsync(
HttpClient client,
int schoolId,
string? classId = null,
string? personId = null)
{
var query = "lang=ru";
if (classId is not null)
{
query += $"&classId={Uri.EscapeDataString(classId)}";
}
if (personId is not null)
{
query += $"&personId={Uri.EscapeDataString(personId)}";
}
var table = await client.GetFromJsonAsync(
$"/api/schools/{schoolId}/timetable?{query}",
TestContext.Current.CancellationToken);
Assert.NotNull(table);
return table;
}
private static async Task GetStaffingAsync(HttpClient client, int schoolId)
{
var staffing = await client.GetFromJsonAsync(
$"/api/schools/{schoolId}/staffing?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task HireAsync(HttpClient client, int schoolId, string personId, string position)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/staff/hire",
new { personId, position },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
private static async Task AssignAsync(HttpClient client, int schoolId, string personId, string subject)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(personId)}/subjects",
new { subject },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
private static async Task ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
return problem?.Code;
}
private sealed record ProblemResponse(string? Code);
private sealed record TimetableResponse(
int WeekDays,
int LessonCount,
IReadOnlyList Lessons,
IReadOnlyList Uncovered,
IReadOnlyList Classes,
IReadOnlyList Rooms);
private sealed record LessonResponse(
string ClassId,
int ClassYear,
string ClassLetter,
string Subject,
string SubjectLabel,
string TeacherId,
string TeacherName,
string RoomId,
string RoomLabel,
int Day,
int Period,
bool Locked);
private sealed record ClassResponse(string Id, int Year, string Letter);
private sealed record RoomResponse(string Id, string Label);
private sealed record UncoveredResponse(
string ClassId,
int ClassYear,
string ClassLetter,
string Subject,
string SubjectLabel,
int Hours);
private sealed record StaffingResponse(
IReadOnlyList Applicants,
IReadOnlyList Staff);
private sealed record ApplicantResponse(string Id);
private sealed record StaffMemberResponse(string Id);
}