Files
h-school/tests/HSchool.AppHost.Tests/SchoolApiTests.cs
T
2026-08-18 23:51:10 +03:00

391 lines
15 KiB
C#

using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
/// <summary>
/// The main menu's HTTP surface: list, create, delete. Tests share one AppHost, so each of them
/// starts from an empty list rather than assuming one.
/// </summary>
[Collection(AppHostCollection.Name)]
public class SchoolApiTests(AppHostFixture fixture)
{
private static readonly DateTime ExpectedDefaultStart = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Schools_ReportTheConfiguredLimitAndDefaultStartDate()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var state = await GetSchoolsAsync(client);
Assert.Equal(6, state.MaxSchools);
Assert.Equal(ExpectedDefaultStart, state.DefaultStartDate);
// Without the "Z" the browser would read the start date in its own time zone and the
// creation form would offer the wrong hour.
Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind);
Assert.Equal(5d, state.GameMinutesPerRealSecond);
Assert.Empty(state.Schools);
}
[Fact]
public async Task CreateSchool_StartsAtTheRequestedDateAndRunning()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "Гимназия у моря", ExpectedDefaultStart);
Assert.Equal("Гимназия у моря", created.Name);
Assert.Equal(ExpectedDefaultStart, created.GameTime);
// Schools live from the moment they exist, whether or not anybody is inside.
Assert.True(created.Running);
var state = await GetSchoolsAsync(client);
Assert.Contains(state.Schools, school => school.Id == created.Id);
}
[Fact]
public async Task CreateSchool_BeyondTheLimit_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var state = await GetSchoolsAsync(client);
for (var i = 0; i < state.MaxSchools; i++)
{
await CreateAsync(client, $"Школа {i + 1}", ExpectedDefaultStart);
}
using var response = await PostAsync(client, "Лишняя", ExpectedDefaultStart);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("school-limit-reached", await ProblemCodeAsync(response));
Assert.Equal(state.MaxSchools, (await GetSchoolsAsync(client)).Schools.Count);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task CreateSchool_WithABlankName_IsRejected(string name)
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await PostAsync(client, name, ExpectedDefaultStart);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-name", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnImpossibleStartDate_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await PostAsync(client, "Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-start-date", await ProblemCodeAsync(response));
}
[Fact]
public async Task DeleteSchool_RemovesItAndFreesASlot()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "На удаление", ExpectedDefaultStart);
using var response = await client.DeleteAsync($"/api/schools/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.DoesNotContain((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id);
}
[Fact]
public async Task DeleteSchool_ThatDoesNotExist_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
using var response = await client.DeleteAsync("/api/schools/999999", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task RandomName_IsUsableAsIs()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var suggestion = await client.GetFromJsonAsync<RandomNameResponse>(
"/api/schools/random-name",
TestContext.Current.CancellationToken);
Assert.NotNull(suggestion);
Assert.False(string.IsNullOrWhiteSpace(suggestion.Name));
var created = await CreateAsync(client, suggestion.Name, ExpectedDefaultStart);
Assert.Equal(suggestion.Name, created.Name);
}
[Fact]
public async Task Mods_ListCoreAsRequired()
{
using var client = fixture.App.CreateHttpClient("server");
var response = await client.GetFromJsonAsync<ModsResponse>("/api/mods", TestContext.Current.CancellationToken);
Assert.NotNull(response);
var core = Assert.Single(response.Mods, pack => pack.Id == "core");
Assert.True(core.Required);
}
[Fact]
public async Task Catalog_LabelsDefsInTheRequestedLanguage()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("Principal's office", Assert.Single(en.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label);
Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label);
Assert.Equal("Начальные классы", Assert.Single(ru.Subjects, subject => subject.DefName == "PrimarySchool").Label);
Assert.Equal("Primary", Assert.Single(en.Subjects, subject => subject.DefName == "PrimarySchool").Label);
var classroom = Assert.Single(ru.Rooms, room => room.DefName == "Classroom");
Assert.True(classroom.Homeroom);
Assert.Equal("StudentDesk", classroom.SeatThing);
Assert.Equal(16, classroom.DefaultSeats);
Assert.Empty(classroom.Slots);
Assert.Empty(classroom.Positions);
}
[Fact]
public async Task CreateSchool_WithABrokenMap_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Дырявая",
startDate = ExpectedDefaultStart,
map = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = Array.Empty<object>(),
floors = Array.Empty<object>(),
rooms = Array.Empty<object>(),
links = Array.Empty<object>(),
},
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-map", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownMod_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужой мод",
startDate = ExpectedDefaultStart,
modIds = new[] { "no-such-mod" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownNameSet_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужие имена",
startDate = ExpectedDefaultStart,
nameSetId = "NoSuchNames",
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-name-set", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithACustomConnectedMap_Succeeds()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateWithMapAsync(client, "Своя карта", ExpectedDefaultStart, SimpleCustomMap);
Assert.Equal("Своя карта", created.Name);
Assert.Contains((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id);
}
[Fact]
public async Task Status_ReportsTheLoopAndTheLimit()
{
using var client = fixture.App.CreateHttpClient("server");
var status = await client.GetFromJsonAsync<StatusResponse>("/api/status", TestContext.Current.CancellationToken);
Assert.NotNull(status);
Assert.Equal(20, status.TickRate);
Assert.Equal(6, status.MaxSchools);
Assert.True(status.Tick > 0, "The loop should have ticked by now.");
}
[Fact]
public async Task ReloadFromDisk_RestoresCreatedSchools()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "После перезагрузки", ExpectedDefaultStart);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = (await GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id);
Assert.Equal(created.Name, restored.Name);
Assert.True(restored.Running);
}
internal static async Task ResetAsync(HttpClient client)
{
var state = await GetSchoolsAsync(client);
foreach (var school in state.Schools)
{
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
}
internal static async Task<SchoolsResponse> GetSchoolsAsync(HttpClient client)
{
var state = await client.GetFromJsonAsync<SchoolsResponse>("/api/schools", TestContext.Current.CancellationToken);
Assert.NotNull(state);
return state;
}
internal static async Task<SchoolResponse> CreateAsync(HttpClient client, string name, DateTime startDate)
{
using var response = await PostAsync(client, name, startDate);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
internal static async Task<SchoolResponse> CreateWithMapAsync(HttpClient client, string name, DateTime startDate, object map)
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new { name, startDate, map },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
internal static readonly object SimpleCustomMap = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = new[] { new { id = "main", def = "MainBuilding" } },
floors = new[] { new { id = "floor-1", def = "StandardFloor", building = "main", label = "1" } },
rooms = new[]
{
new { id = "office", def = "PrincipalsOffice", building = "main", floor = "floor-1", slots = Array.Empty<object>() },
},
links = new[] { new { a = "yard", b = "office" } },
};
private static Task<HttpResponseMessage> PostAsync(HttpClient client, string name, DateTime startDate) =>
client.PostAsJsonAsync(
"/api/schools",
new { name, startDate },
TestContext.Current.CancellationToken);
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
return problem?.Code;
}
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
internal sealed record SchoolsResponse(
int MaxSchools,
DateTime DefaultStartDate,
double GameMinutesPerRealSecond,
IReadOnlyList<SchoolResponse> Schools);
private sealed record RandomNameResponse(string Name);
private sealed record ProblemResponse(string? Code);
private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
private sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
private sealed record ModInfoResponse(string Id, bool Required);
private sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
IReadOnlyList<DefInfoResponse> Buildings,
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets,
IReadOnlyList<SubjectInfoResponse> Subjects,
MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label);
private sealed record RoomInfoResponse(
string DefName,
string Label,
bool Homeroom,
string? SeatThing,
int DefaultSeats,
IReadOnlyList<RoomSlotResponse> Slots,
IReadOnlyList<string> Positions);
private sealed record RoomSlotResponse(string Key, string Thing);
private sealed record SubjectInfoResponse(string DefName, string Label);
private sealed record MapLayoutResponse(TerritoryResponse? Territory);
private sealed record TerritoryResponse(string Id, string Def);
}