Files
h-school/tests/HSchool.AppHost.Tests/StaffingApiTests.cs
T
Leonid Pershin 25ce26064d
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 14s
Update staffing management to enhance payroll and hiring functionalities
- Increased the `MonthlyPayrollCap` from 10,000 to 40,000, allowing for greater flexibility in hiring and subject assignments.
- Revised payroll calculation logic to ensure that staff members are compensated based on their actual weekly hours, with a minimum payment reflecting one full rate.
- Updated documentation to clarify the new payroll structure and its implications for hiring and subject assignments.
- Enhanced tests to validate the new payroll cap and ensure proper functionality in staffing scenarios, including the handling of uncovered subjects.
- Improved localization strings to reflect changes in staffing and payroll terminology.
2026-08-19 12:51:38 +03:00

316 lines
14 KiB
C#

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(40_000f, staffing.Allocated);
Assert.Equal(0f, staffing.Payroll);
Assert.Equal(40_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<PersonCardResponse>(
$"/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<PersonCardResponse>(
$"/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<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(40_000f, problem?.Allocated);
Assert.True(problem?.Payroll <= 40_000f);
Assert.True(problem?.Attempted > 40_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");
// 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<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,
IReadOnlyList<DefLabelResponse> Positions,
IReadOnlyList<UncoveredSubjectResponse> 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<LabeledStatResponse> 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<DefLabelResponse> Subjects);
private sealed record DefLabelResponse(string DefName, string Label);
private sealed record PersonCardResponse(string Id, IReadOnlyList<string> Roles, IReadOnlyList<LabeledStatResponse> Skills);
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);
}