using System.Net.Http.Json; namespace HSchool.AppHost.Tests; [Collection(AppHostCollection.Name)] public class StaffingApiTests(AppHostFixture fixture) { private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc); [Fact] public async Task GetStaffing_UnknownSchool_IsNotFound() { using var client = fixture.App.CreateHttpClient("server"); using var response = await client.GetAsync("/api/schools/999999/staffing", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal("unknown-school", await ProblemCodeAsync(response)); } [Fact] public async Task GetStaffing_EmptySchool_HasPoolAndNoPayroll() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Штат пустой", Start); var staffing = await GetStaffingAsync(client, school.Id); Assert.Equal(100_000f, staffing.Allocated); Assert.Equal(0f, staffing.Payroll); Assert.Equal(100_000f, staffing.Remaining); Assert.Equal(12, staffing.Applicants.Count); Assert.Empty(staffing.Staff); Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics"); Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool"); Assert.NotEmpty(staffing.Positions); Assert.Contains(staffing.Positions, position => position.DefName == "Teacher"); Assert.Contains(staffing.Subjects, subject => subject.DefName == "Mathematics"); Assert.All(staffing.Applicants, applicant => Assert.NotEmpty(applicant.Skills)); } [Fact] public async Task Card_OpensAGeneratedApplicant() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Штат карточка пула", Start); var staffing = await GetStaffingAsync(client, school.Id); var generated = staffing.Applicants.First(applicant => !applicant.IsParent); var card = await client.GetFromJsonAsync( $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(generated.Id)}?lang=ru", TestContext.Current.CancellationToken); Assert.NotNull(card); Assert.Equal(generated.Id, card.Id); Assert.NotEmpty(card.Skills); } [Fact] public async Task Hire_ParentKeepsBothRolesOnTheCard() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Штат родитель", Start); var before = await GetStaffingAsync(client, school.Id); var parent = before.Applicants.First(applicant => applicant.IsParent); await HireAsync(client, school.Id, parent.Id, "Teacher"); var card = await client.GetFromJsonAsync( $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(parent.Id)}?lang=ru", TestContext.Current.CancellationToken); Assert.NotNull(card); Assert.Contains("parent", card.Roles); Assert.Contains("staff", card.Roles); } [Fact] public async Task Hire_AddsStaff_RemovesApplicant_AndRejectsASecondHireOfTheSamePerson() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Штат наём", Start); var before = await GetStaffingAsync(client, school.Id); var applicant = before.Applicants[0]; var after = await HireAsync(client, school.Id, applicant.Id, "Teacher"); Assert.Contains(after.Staff, member => member.Id == applicant.Id && member.Position == "Teacher"); Assert.DoesNotContain(after.Applicants, candidate => candidate.Id == applicant.Id); Assert.True(after.Payroll > 0f); Assert.True(after.Payroll <= after.Allocated); using var again = await client.PostAsJsonAsync( $"/api/schools/{school.Id}/staff/hire", new { personId = applicant.Id, position = "Teacher" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); Assert.Equal("already-hired", await ProblemCodeAsync(again)); var people = await client.GetFromJsonAsync( $"/api/schools/{school.Id}/people?role=staff&pageSize=10", TestContext.Current.CancellationToken); Assert.NotNull(people); Assert.Contains(people.People, person => person.Id == applicant.Id); } [Fact] public async Task Hire_UnknownApplicant_IsNotFound() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Штат неизвестный", Start); using var response = await client.PostAsJsonAsync( $"/api/schools/{school.Id}/staff/hire", new { personId = "nobody", position = "Teacher" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal("unknown-applicant", await ProblemCodeAsync(response)); } [Fact] public async Task Hire_IdlePool_FitsUnderTheCap() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Штат предел", Start); var staffing = await GetStaffingAsync(client, school.Id); while (staffing.Applicants.Count > 0) { var applicant = staffing.Applicants[0]; using var response = await client.PostAsJsonAsync( $"/api/schools/{school.Id}/staff/hire", new { personId = applicant.Id, position = "Teacher" }, TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(staffing); } Assert.Equal(100_000f, staffing.Allocated); Assert.True(staffing.Payroll > 0f); Assert.True(staffing.Payroll <= staffing.Allocated); Assert.Equal(12, staffing.Staff.Count); } [Fact] public async Task AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Штат предметы", Start); var before = await GetStaffingAsync(client, school.Id); var hired = await HireAsync(client, school.Id, before.Applicants[0].Id, "Teacher"); var teacher = hired.Staff.Single(); var assigned = await AssignAsync(client, school.Id, teacher.Id, "Mathematics"); // The first subject already costs: 35 curriculum hours is well past one full rate. Assert.True(assigned.Payroll > hired.Payroll); assigned = await AssignAsync(client, school.Id, teacher.Id, "RussianLanguage"); Assert.True(assigned.Payroll > hired.Payroll); Assert.Equal(2, assigned.Staff.Single().Subjects.Count); using var over = await client.PostAsJsonAsync( $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects", new { subject = "Literature" }, TestContext.Current.CancellationToken); // Cheap asks may still fit; only the numbers matter when the cap actually bites. if (over.StatusCode == HttpStatusCode.Conflict) { var problem = await over.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.Equal("payroll-exceeded", problem?.Code); Assert.True(problem?.Attempted > problem?.Allocated); Assert.Equal(assigned.Payroll, problem?.Payroll); } else { over.EnsureSuccessStatusCode(); assigned = await over.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(assigned); } var payrollWithSubjects = assigned.Payroll; using var unassign = await client.DeleteAsync( $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects/RussianLanguage", TestContext.Current.CancellationToken); unassign.EnsureSuccessStatusCode(); var afterDrop = await unassign.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(afterDrop); Assert.True(afterDrop.Payroll < payrollWithSubjects); using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); reload.EnsureSuccessStatusCode(); var restored = await GetStaffingAsync(client, school.Id); Assert.Equal(afterDrop.Payroll, restored.Payroll); Assert.Equal(teacher.Id, restored.Staff.Single().Id); Assert.Equal( afterDrop.Staff.Single().Subjects.Select(subject => subject.DefName), restored.Staff.Single().Subjects.Select(subject => subject.DefName)); } 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(); var staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(staffing); return staffing; } 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(); var staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(staffing); return staffing; } 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, float? Allocated, float? Payroll, float? Remaining, float? Attempted); private sealed record StaffingResponse( float Allocated, float Payroll, float Remaining, IReadOnlyList Uncovered, IReadOnlyList Applicants, IReadOnlyList Staff, IReadOnlyList Positions, IReadOnlyList Subjects); private sealed record UncoveredSubjectResponse(string DefName, string Label, int GradeMin, int GradeMax, int HoursPerWeek); private sealed record ApplicantResponse( string Id, string FullName, bool Female, int Age, bool IsParent, float HourlyWageAsk, float MonthlyBase, IReadOnlyList Skills); private sealed record LabeledStatResponse(string Id, string Label, string Value); private sealed record StaffMemberResponse( string Id, string FullName, bool Female, int Age, bool IsParent, string Position, string PositionLabel, float HourlyWageAsk, float MonthlyPay, IReadOnlyList Subjects); private sealed record DefLabelResponse(string DefName, string Label); private sealed record PersonCardResponse(string Id, IReadOnlyList Roles, IReadOnlyList Skills); private sealed record PeopleListResponse( int Total, int Page, int PageSize, IReadOnlyList People, object Filters); private sealed record PersonListItemResponse( string Id, string FullName, string Surname, string Given, string Patronymic, bool Female, int Age, IReadOnlyList Roles, int? ClassYear, string? ClassLetter, string? Position, string? PositionLabel); }