Add class-teacher slots on SchoolClass with owner-only assign/clear.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 09:37:37 +03:00
co-authored by Cursor
parent 99eca80d6d
commit 4096d5306e
22 changed files with 992 additions and 22 deletions
@@ -0,0 +1,251 @@
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class ClassTeacherApiTests(AppHostFixture fixture)
{
private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Assign_WritesId_AndSurvivesReload()
{
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 teacher = staffing.Applicants[0];
await HireAsync(client, school.Id, teacher.Id, "Teacher");
var schoolClass = Assert.Single((await GetStaffingAsync(client, school.Id)).Classes.Take(1));
var assigned = await AssignClassTeacherAsync(client, school.Id, schoolClass.Id, teacher.Id);
var slot = Assert.Single(assigned.Classes, row => row.Id == schoolClass.Id);
Assert.Equal(teacher.Id, slot.ClassTeacherId);
Assert.False(string.IsNullOrWhiteSpace(slot.ClassTeacherName));
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var after = await GetStaffingAsync(client, school.Id);
var reloaded = Assert.Single(after.Classes, row => row.Id == schoolClass.Id);
Assert.Equal(teacher.Id, reloaded.ClassTeacherId);
var pupil = await FirstPupilOfClassAsync(client, school.Id, schoolClass.Id);
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(pupil)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(card);
Assert.Equal(teacher.Id, card.ClassTeacherId);
Assert.Equal(reloaded.ClassTeacherName, card.ClassTeacherName);
}
[Fact]
public async Task Assign_RejectsNonTeacherAndUnknownPerson()
{
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 schoolClass = staffing.Classes[0];
var pupil = await FirstPupilOfClassAsync(client, school.Id, schoolClass.Id);
using var notTeacher = await client.PutAsJsonAsync(
$"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher",
new { personId = pupil },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, notTeacher.StatusCode);
Assert.Equal("not-teacher", await ProblemCodeAsync(notTeacher));
using var unknown = await client.PutAsJsonAsync(
$"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher",
new { personId = "nobody" },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
Assert.Equal("unknown-person", await ProblemCodeAsync(unknown));
}
[Fact]
public async Task Guest_CannotAssign()
{
using var wipe = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(wipe);
var owner = await CreateUserAsync("ClassTeacherOwner");
var guest = await CreateUserAsync("ClassTeacherGuest");
await ResetUserAsync(owner);
var school = await SchoolApiTests.CreateAsync(owner, "Чужой классный", Start);
var staffing = await GetStaffingAsync(owner, school.Id);
var schoolClass = staffing.Classes[0];
using var response = await guest.PutAsJsonAsync(
$"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher",
new { personId = "a0.p0" },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal("not-owner", await ProblemCodeAsync(response));
owner.Dispose();
guest.Dispose();
using var cleanup = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(cleanup);
}
[Fact]
public async Task Clear_EmptiesSlot_AndClassWithoutTeacherIsValid()
{
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 teacher = staffing.Applicants[0];
await HireAsync(client, school.Id, teacher.Id, "Teacher");
var schoolClass = (await GetStaffingAsync(client, school.Id)).Classes[0];
await AssignClassTeacherAsync(client, school.Id, schoolClass.Id, teacher.Id);
using var clear = await client.DeleteAsync(
$"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher?lang=ru",
TestContext.Current.CancellationToken);
clear.EnsureSuccessStatusCode();
var after = await clear.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(after);
var slot = Assert.Single(after.Classes, row => row.Id == schoolClass.Id);
Assert.Null(slot.ClassTeacherId);
Assert.Null(slot.ClassTeacherName);
Assert.NotEmpty(after.Classes);
}
[Fact]
public async Task TwoClasses_MayShareTheSameTeacher()
{
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.True(staffing.Classes.Count >= 2);
var teacher = staffing.Applicants[0];
await HireAsync(client, school.Id, teacher.Id, "Teacher");
var first = staffing.Classes[0];
var second = staffing.Classes[1];
await AssignClassTeacherAsync(client, school.Id, first.Id, teacher.Id);
var after = await AssignClassTeacherAsync(client, school.Id, second.Id, teacher.Id);
Assert.Equal(teacher.Id, Assert.Single(after.Classes, row => row.Id == first.Id).ClassTeacherId);
Assert.Equal(teacher.Id, Assert.Single(after.Classes, row => row.Id == second.Id).ClassTeacherId);
}
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 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<StaffingResponse> AssignClassTeacherAsync(
HttpClient client,
int schoolId,
string classId,
string personId)
{
using var response = await client.PutAsJsonAsync(
$"/api/schools/{schoolId}/classes/{Uri.EscapeDataString(classId)}/class-teacher?lang=ru",
new { personId },
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> FirstPupilOfClassAsync(HttpClient client, int schoolId, string classId)
{
var people = await client.GetFromJsonAsync<PeopleListResponse>(
$"/api/schools/{schoolId}/people?role=student&pageSize=50",
TestContext.Current.CancellationToken);
Assert.NotNull(people);
foreach (var person in people.People)
{
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{schoolId}/people/{Uri.EscapeDataString(person.Id)}",
TestContext.Current.CancellationToken);
if (card?.ClassId == classId)
{
return person.Id;
}
}
Assert.Fail($"No pupil found for class {classId}.");
return string.Empty;
}
private async Task<HttpClient> CreateUserAsync(string userName) =>
await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, userName);
private static async Task ResetUserAsync(HttpClient client)
{
await SchoolApiTests.LoginAsync(client);
var state = await SchoolApiTests.GetSchoolsAsync(client);
foreach (var school in state.Schools)
{
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
foreach (var other in state.Others)
{
using var response = await client.DeleteAsync($"/api/schools/{other.Id}", TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Forbidden)
{
continue;
}
response.EnsureSuccessStatusCode();
}
}
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);
private sealed record StaffingResponse(
float Allocated,
float Payroll,
float Remaining,
IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> Staff,
IReadOnlyList<ClassTeacherSlotResponse> Classes);
private sealed record ApplicantResponse(string Id, string FullName);
private sealed record StaffMemberResponse(string Id, string FullName, string Position);
private sealed record ClassTeacherSlotResponse(
string Id,
int Year,
string Letter,
string? ClassTeacherId,
string? ClassTeacherName);
private sealed record PeopleListResponse(IReadOnlyList<PersonListItemResponse> People);
private sealed record PersonListItemResponse(string Id);
private sealed record PersonCardResponse(string Id, string? ClassId, string? ClassTeacherId, string? ClassTeacherName);
}