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(5d, 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); } [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.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); 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"); } [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(), floors = Array.Empty(), rooms = Array.Empty(), links = Array.Empty(), }, }, 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("/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 GetSchoolsAsync(HttpClient client) { var state = await client.GetFromJsonAsync("/api/schools", TestContext.Current.CancellationToken); Assert.NotNull(state); return state; } internal static async Task CreateAsync(HttpClient client, string name, DateTime startDate) { using var response = await PostAsync(client, name, startDate); response.EnsureSuccessStatusCode(); var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(created); return created; } internal static async Task 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(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() }, }, links = new[] { new { a = "yard", b = "office" } }, }; private static Task PostAsync(HttpClient client, string name, DateTime startDate) => client.PostAsJsonAsync( "/api/schools", new { name, startDate }, TestContext.Current.CancellationToken); private static async Task ProblemCodeAsync(HttpResponseMessage response) { var problem = await response.Content.ReadFromJsonAsync(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, int SchoolWeekDays, IReadOnlyList 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 Mods); private sealed record ModInfoResponse(string Id, bool Required); private sealed record CatalogResponse( IReadOnlyList Territories, IReadOnlyList Buildings, IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, IReadOnlyList NameSets, IReadOnlyList Subjects, DayFrameResponse? DayFrame, IReadOnlyList Holidays, 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 Slots, IReadOnlyList Positions); private sealed record RoomSlotResponse(string Key, string Thing); private sealed record SubjectInfoResponse(string DefName, string Label, string? Room); private sealed record DayFrameResponse( string DefName, string Label, string FirstLesson, int LessonCount, int LessonMinutes, int BreakMinutes, int LongBreakAfter, int LongBreakMinutes); private sealed record HolidayInfoResponse(string DefName, string Label); private sealed record MapLayoutResponse(TerritoryResponse? Territory); private sealed record TerritoryResponse(string Id, string Def); }