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, 4, 3, 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( "/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 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; } 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, 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); }