Enhance staffing management in school simulation by introducing API endpoints for hiring staff and assigning subjects. Implement payroll cap validation to ensure hiring and subject assignments do not exceed the allocated budget. Update the simulation options to include a monthly payroll cap and revise related classes to support new staffing functionalities. Enhance documentation to reflect these changes and update tests to validate the new features.

This commit is contained in:
Leonid Pershin
2026-08-19 00:19:09 +03:00
parent c189578680
commit 94842ab192
24 changed files with 1444 additions and 27 deletions
@@ -0,0 +1,260 @@
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(10_000f, staffing.Allocated);
Assert.Equal(0f, staffing.Payroll);
Assert.Equal(10_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");
}
[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<PeopleListResponse>(
$"/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_PastTheCap_IsRejectedWithTheNumbers()
{
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);
ProblemResponse? problem = null;
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);
if (response.StatusCode == HttpStatusCode.Conflict)
{
problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
break;
}
response.EnsureSuccessStatusCode();
staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
}
Assert.Equal("payroll-exceeded", problem?.Code);
Assert.Equal(10_000f, problem?.Allocated);
Assert.True(problem?.Payroll <= 10_000f);
Assert.True(problem?.Attempted > 10_000f);
Assert.True(staffing.Staff.Count >= 1);
}
[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");
Assert.Equal(hired.Payroll, assigned.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<ProblemResponse>(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<StaffingResponse>(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<StaffingResponse>(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<StaffingResponse> GetStaffingAsync(HttpClient client, int schoolId)
{
var staffing = await client.GetFromJsonAsync<StaffingResponse>(
$"/api/schools/{schoolId}/staffing?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<StaffingResponse> 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<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<StaffingResponse> 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<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(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<UncoveredSubjectResponse> Uncovered,
IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> Staff);
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);
private sealed record StaffMemberResponse(
string Id,
string FullName,
bool Female,
int Age,
bool IsParent,
string Position,
string PositionLabel,
float HourlyWageAsk,
float MonthlyPay,
IReadOnlyList<DefLabelResponse> Subjects);
private sealed record DefLabelResponse(string DefName, string Label);
private sealed record PeopleListResponse(
int Total,
int Page,
int PageSize,
IReadOnlyList<PersonListItemResponse> People,
object Filters);
private sealed record PersonListItemResponse(
string Id,
string FullName,
string Surname,
string Given,
string Patronymic,
bool Female,
int Age,
IReadOnlyList<string> Roles,
int? ClassYear,
string? ClassLetter,
string? Position,
string? PositionLabel);
}