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); }