using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
///
/// 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.
///
[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(1d, state.GameMinutesPerRealSecond);
Assert.Equal(5, state.SchoolWeekDays);
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(
"/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("/api/mods", TestContext.Current.CancellationToken);
Assert.NotNull(response);
var core = Assert.Single(response.Mods, pack => pack.Id == "core");
Assert.True(core.Required);
Assert.Equal("Базовая игра", core.Label);
Assert.False(string.IsNullOrWhiteSpace(core.Version));
Assert.Empty(core.Requires);
}
[Fact]
public async Task Mods_LabelsCoreInTheRequestedLanguage()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync("/api/mods?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync("/api/mods?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Базовая игра", Assert.Single(ru.Mods, pack => pack.Id == "core").Label);
Assert.Equal("Core", Assert.Single(en.Mods, pack => pack.Id == "core").Label);
}
[Fact]
public async Task Mods_PackWithoutManifest_UsesIdAsLabel()
{
using var client = fixture.App.CreateHttpClient("server");
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22plain"))
{
var response = await client.GetFromJsonAsync("/api/mods?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(response);
var pack = Assert.Single(response.Mods, candidate => candidate.Id == "t22plain");
Assert.False(pack.Required);
Assert.Equal("t22plain", pack.Label);
Assert.Equal(string.Empty, pack.Version);
Assert.Empty(pack.Requires);
}
}
[Fact]
public async Task CreateSchool_ReturnsResolvedPackOrder()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "С ванилью", ExpectedDefaultStart);
Assert.Equal(["core"], created.ModIds);
}
[Fact]
public async Task CreateSchool_WithUnselectedDependency_NamesTheMissingPack()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22need", """{ "version": "1", "requires": ["t22ghost"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Без базы",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22need" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
Assert.Equal("missing-mod", problem?.Code);
Assert.Equal("t22ghost", problem?.Missing);
}
}
[Fact]
public async Task CreateSchool_LoadsRequiredPackBeforeDependentEvenIfListedAfter()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22base", """{ "version": "1", "requires": [] }"""))
using (new TempPack(root, "t22addon", """{ "version": "1", "requires": ["t22base"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Сначала зависимость",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22addon", "t22base" },
},
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
Assert.Equal(["core", "t22base", "t22addon"], created?.ModIds);
}
}
[Fact]
public async Task CreateSchool_WithCyclicMods_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22left", """{ "requires": ["t22right"] }"""))
using (new TempPack(root, "t22right", """{ "requires": ["t22left"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Цикл",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22left", "t22right" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("mod-cycle", await ProblemCodeAsync(response));
}
}
[Fact]
public async Task Catalog_LabelsDefsInTheRequestedLanguage()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync("/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.Countries, country => country.DefName == "Russia").Label);
Assert.Equal("Russia", Assert.Single(en.Countries, country => country.DefName == "Russia").Label);
var russia = Assert.Single(ru.Countries, country => country.DefName == "Russia");
Assert.Equal(
["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"],
russia.NativeLanguages.Select(language => language.DefName).ToArray());
Assert.Equal("Белорусский", Assert.Single(russia.NativeLanguages, language => language.DefName == "BelarusianLanguage").Label);
Assert.Equal(["TemperateContinental", "ContinentalCold"], russia.ClimatePresets);
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);
Assert.NotNull(ru.DayFrame);
Assert.Equal("08:30", ru.DayFrame.FirstLesson);
Assert.Equal(7, ru.DayFrame.LessonCount);
Assert.Equal(3, ru.DayFrame.LongBreakAfter);
Assert.Equal("Учебный день", ru.DayFrame.Label);
Assert.NotNull(en.DayFrame);
Assert.Equal("School day", en.DayFrame.Label);
Assert.Equal("GymHall", Assert.Single(ru.Subjects, subject => subject.DefName == "PhysicalEducation").Room);
Assert.Null(Assert.Single(ru.Subjects, subject => subject.DefName == "Mathematics").Room);
Assert.Contains(ru.Holidays, holiday => holiday.DefName == "SpringBreak");
Assert.Contains(ru.Things, thing => thing.DefName == "Chair");
Assert.DoesNotContain(ru.Things, thing => thing.DefName == "TShirt");
Assert.DoesNotContain(ru.Things, thing => thing.DefName == "Textbook");
}
[Fact]
public async Task Catalog_WithAnUnknownMod_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
using var response = await client.GetAsync("/api/catalog?mods=no-such-mod", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
}
///
/// A pack id is a folder name that came from a browser. Anything outside the safe alphabet is
/// refused as unknown before it can be joined onto a path — on both endpoints that take one.
///
[Fact]
public async Task ModId_ThatEscapesTheModsFolder_IsRejectedOnBothEndpoints()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var catalog = await client.GetAsync("/api/catalog?mods=..%2F..%2Fsaves", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, catalog.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(catalog));
using var create = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Побег из mods",
startDate = ExpectedDefaultStart,
modIds = new[] { "../../saves" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(create));
}
[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