341 lines
14 KiB
C#
341 lines
14 KiB
C#
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);
|
||
}
|