using System.Net.Http.Json; namespace HSchool.AppHost.Tests; [Collection(AppHostCollection.Name)] public class PeopleApiTests(AppHostFixture fixture) { private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); [Fact] public async Task List_FiltersByRoleAndYear() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Люди фильтр", Start); var page = await GetPeopleAsync(client, school.Id, "role=student&year=5&pageSize=100"); Assert.NotEmpty(page.People); Assert.Equal(page.People.Count, page.Total); Assert.All(page.People, person => { Assert.Contains("student", person.Roles); Assert.Equal(5, person.ClassYear); }); Assert.Contains(5, page.Filters.Years); } [Fact] public async Task List_SortsBySurname() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Люди сорт", Start); var page = await GetPeopleAsync(client, school.Id, "sort=surname&pageSize=20"); var surnames = page.People.Select(person => person.Surname).ToArray(); // The server alphabetises Ё with Е, so plain ordinal order is the wrong yardstick: it only // agrees because no vanilla surname starts with Ё. Fold the same way the server does. Assert.Equal(surnames.OrderBy(Alphabetised, StringComparer.Ordinal), surnames); } [Fact] public async Task List_PageBounds() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Люди страницы", Start); var first = await GetPeopleAsync(client, school.Id, "page=1&pageSize=10"); Assert.True(first.Total > 10); Assert.Equal(10, first.People.Count); Assert.Equal(1, first.Page); Assert.Equal(10, first.PageSize); var past = await GetPeopleAsync(client, school.Id, "page=999&pageSize=10"); Assert.Equal(first.Total, past.Total); Assert.Empty(past.People); using var zero = await client.GetAsync($"/api/schools/{school.Id}/people?page=0", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, zero.StatusCode); Assert.Equal("invalid-query", await ProblemCodeAsync(zero)); using var huge = await client.GetAsync($"/api/schools/{school.Id}/people?pageSize=101", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, huge.StatusCode); Assert.Equal("invalid-query", await ProblemCodeAsync(huge)); } [Fact] public async Task List_HasPupilsAndParentsButNoStaff() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Пустой штат", Start); var students = await GetPeopleAsync(client, school.Id, "role=student&pageSize=10"); var parents = await GetPeopleAsync(client, school.Id, "role=parent&pageSize=10"); var staff = await GetPeopleAsync(client, school.Id, "role=staff&pageSize=10"); Assert.NotEmpty(students.People); Assert.NotEmpty(parents.People); Assert.Equal(0, staff.Total); Assert.Empty(staff.People); } [Fact] public async Task Directory_ListsRosterIdsAndNames() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Справочник", Start); var directory = await client.GetFromJsonAsync( $"/api/schools/{school.Id}/directory", TestContext.Current.CancellationToken); Assert.NotNull(directory); Assert.NotEmpty(directory.People); Assert.All(directory.People, person => { Assert.False(string.IsNullOrWhiteSpace(person.Id)); Assert.False(string.IsNullOrWhiteSpace(person.FullName)); }); var page = await GetPeopleAsync(client, school.Id, "pageSize=10"); Assert.Contains(directory.People, person => person.Id == page.People[0].Id && person.FullName == page.People[0].FullName); using var missing = await client.GetAsync("/api/schools/999999/directory", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); Assert.Equal("unknown-school", await ProblemCodeAsync(missing)); } [Fact] public async Task List_UnknownSchool_IsNotFound() { using var client = fixture.App.CreateHttpClient("server"); using var response = await client.GetAsync("/api/schools/999999/people", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal("unknown-school", await ProblemCodeAsync(response)); } [Fact] public async Task Card_UnknownPerson_IsNotFound() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Люди карточка", Start); using var response = await client.GetAsync( $"/api/schools/{school.Id}/people/nobody", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal("unknown-person", await ProblemCodeAsync(response)); } [Fact] public async Task Card_IncludesFamilyAndLiveNeeds() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Люди семья", Start, seed: 1); var page = await GetPeopleAsync(client, school.Id, "role=student&year=5&pageSize=10"); // Every fifth-to-tenth family is single-parent on purpose (design/people.md). The roster // seed is no longer the school id, but a given page still mixes complete and single-parent // houses, so demanding two parents from whoever sorts first fails on some seeds. Look for // a pupil who has a mother instead of assuming the first one does. PersonCardResponse? card = null; foreach (var candidate in page.People) { var candidateCard = await client.GetFromJsonAsync( $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(candidate.Id)}?lang=ru", TestContext.Current.CancellationToken); Assert.NotNull(candidateCard); Assert.Equal(candidate.Id, candidateCard.Id); Assert.InRange(candidateCard.Family.Parents.Count, 1, 2); if (candidateCard.Family.Parents.Any(parent => parent.Female)) { card = candidateCard; break; } } Assert.NotNull(card); Assert.Contains(card.Needs, need => need.Id == "Sleep" && need.Value == 1f); Assert.NotEmpty(card.Body); Assert.NotEmpty(card.Skills); var mother = card.Family.Parents.Single(parent => parent.Female); var motherCard = await client.GetFromJsonAsync( $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(mother.Id)}?lang=ru", TestContext.Current.CancellationToken); Assert.NotNull(motherCard); Assert.Contains(motherCard.Family.Children, child => child.Id == card.Id); } /// /// Phases 7 and 9 together: the roster survives a restart, and what comes back is the /// composition *after* the first-September intake. Generating from the seed again would /// rebuild the pre-intake roster, so comparing against it is what makes this test bite. /// [Fact] public async Task ReloadAfterTheYearlyIntake_RestoresTheNewComposition() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); // Fifteen game minutes short of 1 September: the intake fires at midnight, and five game // minutes per real second gets the clock there in about three seconds. var school = await SchoolApiTests.CreateAsync( client, "Набор и рестарт", new DateTime(2012, 8, 31, 23, 45, 0, DateTimeKind.Utc)); var before = await GetPeopleAsync(client, school.Id, Everyone); var beforeIds = before.People.Select(person => person.Id).ToArray(); Assert.NotEmpty(beforeIds); var after = await WaitForIntakeAsync(client, school.Id, beforeIds); var afterIds = after.People.Select(person => person.Id).ToArray(); var pupil = after.People.First(person => person.Roles.Contains("student")); var cardBefore = await GetCardAsync(client, school.Id, pupil.Id); using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); reload.EnsureSuccessStatusCode(); var restored = await GetPeopleAsync(client, school.Id, Everyone); Assert.Equal(after.Total, restored.Total); Assert.Equal( after.People.Select(person => (person.Id, person.FullName, person.ClassYear)), restored.People.Select(person => (person.Id, person.FullName, person.ClassYear))); Assert.NotEqual(beforeIds, restored.People.Select(person => person.Id).ToArray()); var cardAfter = await GetCardAsync(client, school.Id, pupil.Id); Assert.Equal(cardBefore.BirthDate, cardAfter.BirthDate); Assert.Equal( cardBefore.Body.Select(stat => (stat.Id, stat.Value)), cardAfter.Body.Select(stat => (stat.Id, stat.Value))); Assert.Equal( cardBefore.Skills.Select(stat => (stat.Id, stat.Value)), cardAfter.Skills.Select(stat => (stat.Id, stat.Value))); Assert.Equal( cardBefore.Traits.Select(trait => trait.DefName), cardAfter.Traits.Select(trait => trait.DefName)); } private const string Everyone = "sort=surname&pageSize=100"; /// Polls the published roster until the intake has replaced the graduating year. private static async Task WaitForIntakeAsync( HttpClient client, int schoolId, IReadOnlyList beforeIds) { for (var attempt = 0; attempt < 60; attempt++) { var page = await GetPeopleAsync(client, schoolId, Everyone); if (!page.People.Select(person => person.Id).SequenceEqual(beforeIds)) { return page; } await Task.Delay(250, TestContext.Current.CancellationToken); } throw new InvalidOperationException("The yearly intake never happened."); } private static async Task GetCardAsync(HttpClient client, int schoolId, string personId) { var card = await client.GetFromJsonAsync( $"/api/schools/{schoolId}/people/{Uri.EscapeDataString(personId)}?lang=ru", TestContext.Current.CancellationToken); Assert.NotNull(card); return card; } /// Mirrors the server's name order: Ё sorts as Е, everything else stays ordinal. private static string Alphabetised(string name) => name.Replace('Ё', 'Е').Replace('ё', 'е'); private static async Task GetPeopleAsync(HttpClient client, int schoolId, string query) { var page = await client.GetFromJsonAsync( $"/api/schools/{schoolId}/people?{query}", TestContext.Current.CancellationToken); Assert.NotNull(page); return page; } 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 PeopleListResponse( int Total, int Page, int PageSize, IReadOnlyList People, PeopleFilterOptionsResponse Filters); private sealed record PeopleFilterOptionsResponse( IReadOnlyList Years, IReadOnlyList Letters, IReadOnlyList Positions); private sealed record DefLabelResponse(string DefName, string Label); 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); private sealed record PersonCardResponse( string Id, string FullName, bool Female, int Age, DateTime BirthDate, IReadOnlyList Roles, int? ClassYear, string? ClassLetter, IReadOnlyList Body, IReadOnlyList Skills, IReadOnlyList Traits, IReadOnlyList Needs, PersonFamilyResponse Family); private sealed record LabeledStatResponse(string Id, string Label, string Value); private sealed record NeedStatResponse(string Id, string Label, float Value); private sealed record PersonFamilyResponse( IReadOnlyList Parents, IReadOnlyList Children, IReadOnlyList Siblings, IReadOnlyList Partners); private sealed record PersonRelResponse(string Id, string FullName, bool Female); private sealed record DirectoryResponse(IReadOnlyList People); private sealed record DirectoryPersonResponse(string Id, string FullName); }