Files
h-school/tests/HSchool.AppHost.Tests/PeopleApiTests.cs
T

341 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DirectoryResponse>(
$"/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<PersonCardResponse>(
$"/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<PersonCardResponse>(
$"/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);
}
/// <summary>
/// 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.
/// </summary>
[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";
/// <summary>Polls the published roster until the intake has replaced the graduating year.</summary>
private static async Task<PeopleListResponse> WaitForIntakeAsync(
HttpClient client,
int schoolId,
IReadOnlyList<string> 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<PersonCardResponse> GetCardAsync(HttpClient client, int schoolId, string personId)
{
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{schoolId}/people/{Uri.EscapeDataString(personId)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(card);
return card;
}
/// <summary>Mirrors the server's name order: Ё sorts as Е, everything else stays ordinal.</summary>
private static string Alphabetised(string name) => name.Replace('Ё', 'Е').Replace('ё', 'е');
private static async Task<PeopleListResponse> GetPeopleAsync(HttpClient client, int schoolId, string query)
{
var page = await client.GetFromJsonAsync<PeopleListResponse>(
$"/api/schools/{schoolId}/people?{query}",
TestContext.Current.CancellationToken);
Assert.NotNull(page);
return page;
}
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 PeopleListResponse(
int Total,
int Page,
int PageSize,
IReadOnlyList<PersonListItemResponse> People,
PeopleFilterOptionsResponse Filters);
private sealed record PeopleFilterOptionsResponse(
IReadOnlyList<int> Years,
IReadOnlyList<string> Letters,
IReadOnlyList<DefLabelResponse> 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<string> Roles,
int? ClassYear,
string? ClassLetter,
string? Position,
string? PositionLabel);
private sealed record PersonCardResponse(
string Id,
string FullName,
bool Female,
int Age,
DateTime BirthDate,
IReadOnlyList<string> Roles,
int? ClassYear,
string? ClassLetter,
IReadOnlyList<LabeledStatResponse> Body,
IReadOnlyList<LabeledStatResponse> Skills,
IReadOnlyList<DefLabelResponse> Traits,
IReadOnlyList<NeedStatResponse> 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<PersonRelResponse> Parents,
IReadOnlyList<PersonRelResponse> Children,
IReadOnlyList<PersonRelResponse> Siblings,
IReadOnlyList<PersonRelResponse> Partners);
private sealed record PersonRelResponse(string Id, string FullName, bool Female);
private sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
private sealed record DirectoryPersonResponse(string Id, string FullName);
}