using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; namespace HSchool.AppHost.Tests; [Collection(AppHostCollection.Name)] public class ServiceabilityTests(AppHostFixture fixture) { private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc); [Fact] public async Task FutureFormatSave_LeavesTheSchoolUnstartedAndTheFileUntouched() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var directory = await SavesDirectoryAsync(client); try { InstallGolden(directory, "current"); var path = Path.Combine(directory, "1.json"); BumpFormat(path, 99); var before = File.ReadAllBytes(path); using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); reload.EnsureSuccessStatusCode(); var state = await SchoolApiTests.GetSchoolsAsync(client); Assert.Empty(state.Schools); var broken = Assert.Single(state.Others); Assert.Equal(1, broken.Id); using var people = await client.GetAsync( $"/api/schools/{broken.Id}/people?pageSize=1", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, people.StatusCode); Assert.Equal(before, File.ReadAllBytes(path)); } finally { DeleteSchoolFiles(directory, 1); using var cleanup = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); cleanup.EnsureSuccessStatusCode(); } } [Fact] public async Task CurrentFormatSave_WithoutCountryId_LeavesTheSchoolUnstartedAndTheFileUntouched() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var directory = await SavesDirectoryAsync(client); try { InstallGolden(directory, "current"); var path = Path.Combine(directory, "1.json"); BumpFormat(path, 3); var before = File.ReadAllBytes(path); using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); reload.EnsureSuccessStatusCode(); var state = await SchoolApiTests.GetSchoolsAsync(client); Assert.Empty(state.Schools); var broken = Assert.Single(state.Others); Assert.Equal(1, broken.Id); using var people = await client.GetAsync( $"/api/schools/{broken.Id}/people?pageSize=1", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, people.StatusCode); Assert.Equal(before, File.ReadAllBytes(path)); } finally { DeleteSchoolFiles(directory, 1); using var cleanup = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); cleanup.EnsureSuccessStatusCode(); } } [Fact] public async Task MissingModFolder_LeavesTheSchoolUnstartedAndTheFileUntouched() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var created = await SchoolApiTests.CreateAsync(client, "Без папки мода", TuesdayMorning); var directory = await SavesDirectoryAsync(client); var path = Path.Combine(directory, $"{created.Id}.json"); var node = JsonNode.Parse(File.ReadAllText(path)) ?? throw new InvalidOperationException($"Save {path} parsed to nothing."); var mods = node["modIds"] as JsonArray ?? throw new InvalidOperationException($"Save {path} has no modIds."); mods.Add("missing-pack-slice1-recheck"); var patched = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); try { await SchoolApiTests.ResetAsync(client); File.WriteAllText(path, patched); var before = File.ReadAllBytes(path); using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); reload.EnsureSuccessStatusCode(); Assert.Equal(before, File.ReadAllBytes(path)); var state = await SchoolApiTests.GetSchoolsAsync(client); var mine = state.Schools.FirstOrDefault(school => school.Id == created.Id); var other = state.Others.FirstOrDefault(school => school.Id == created.Id); Assert.True(mine is not null || other is not null); Assert.False(mine?.Running ?? other?.Running ?? true); using var people = await client.GetAsync( $"/api/schools/{created.Id}/people?pageSize=1", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, people.StatusCode); } finally { DeleteSchoolFiles(directory, created.Id); using var cleanup = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); cleanup.EnsureSuccessStatusCode(); } } [Fact] public async Task Dump_ReturnsPeopleNodesAndTimetable() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); var school = await SchoolApiTests.CreateAsync(client, "Дамп", TuesdayMorning); // Create starts the clock. Presence still goes out while paused (often empty); waiting // for people then hangs. Hire, keep time moving, then dump. Assert.True(school.Running); await HireMathAsync(client, school.Id); var dump = await WaitUntilSomeoneIsOnANodeAsync(client, school.Id); Assert.Equal(school.Id, dump.Id); Assert.True(dump.Running); Assert.NotEmpty(dump.People); Assert.Contains(dump.People, person => !string.IsNullOrWhiteSpace(person.NodeId)); Assert.All(dump.People, person => { Assert.False(string.IsNullOrWhiteSpace(person.Id)); Assert.False(string.IsNullOrWhiteSpace(person.FullName)); Assert.NotEmpty(person.Needs); }); Assert.Contains(dump.Lessons, lesson => lesson.Subject == "Mathematics"); } [Fact] public async Task Dump_UnknownSchool_IsNotFound() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.LoginAsync(client); using var response = await client.GetAsync("/api/dev/schools/999999/dump", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal("unknown-school", await ProblemCodeAsync(response)); } /// /// Polls the dump while the clock is moving. A paused school would keep answering with empty /// occupancy; fail immediately instead of waiting that out. /// private static async Task WaitUntilSomeoneIsOnANodeAsync(HttpClient client, int schoolId) { SchoolDumpResponse? dump = null; for (var attempt = 0; attempt < 120; attempt++) { dump = await client.GetFromJsonAsync( $"/api/dev/schools/{schoolId}/dump", TestContext.Current.CancellationToken); Assert.NotNull(dump); if (!dump.Running) { Assert.Fail("Clock is paused; occupancy stays empty and this wait would hang."); } if (dump.People.Any(person => !string.IsNullOrWhiteSpace(person.NodeId))) { return dump; } await Task.Delay(250, TestContext.Current.CancellationToken); } throw new InvalidOperationException( $"Nobody had a NodeId after hire with the clock running at {dump?.GameTime:HH:mm}."); } private static async Task HireMathAsync(HttpClient client, int schoolId) { var staffing = await client.GetFromJsonAsync( $"/api/schools/{schoolId}/staffing", TestContext.Current.CancellationToken); Assert.NotNull(staffing); var applicant = Assert.Single(staffing.Applicants.Take(1)); using var hire = await client.PostAsJsonAsync( $"/api/schools/{schoolId}/staff/hire", new { personId = applicant.Id, position = "Teacher" }, TestContext.Current.CancellationToken); hire.EnsureSuccessStatusCode(); using var assign = await client.PostAsJsonAsync( $"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(applicant.Id)}/subjects", new { subject = "Mathematics" }, TestContext.Current.CancellationToken); assign.EnsureSuccessStatusCode(); } private static async Task SavesDirectoryAsync(HttpClient client) { var payload = await client.GetFromJsonAsync( "/api/dev/saves-directory", TestContext.Current.CancellationToken); Assert.NotNull(payload); Assert.False(string.IsNullOrWhiteSpace(payload.Path)); return payload.Path; } private static void InstallGolden(string directory, string folder) { var source = Path.Combine(AppContext.BaseDirectory, "golden", folder); foreach (var file in Directory.EnumerateFiles(source)) { File.Copy(file, Path.Combine(directory, Path.GetFileName(file)), overwrite: true); } } private static void BumpFormat(string path, int format) { var node = JsonNode.Parse(File.ReadAllText(path)) ?? throw new InvalidOperationException($"Save {path} parsed to nothing."); node["format"] = format; File.WriteAllText(path, node.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); } private static void DeleteSchoolFiles(string directory, int id) { foreach (var name in new[] { $"{id}.json", $"{id}.people.json", $"{id}.timetable.json" }) { var path = Path.Combine(directory, name); if (File.Exists(path)) { File.Delete(path); } } } private static async Task ProblemCodeAsync(HttpResponseMessage response) { var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); return problem?.Code; } private sealed record SavesDirectoryResponse(string Path); private sealed record ProblemResponse(string? Code); private sealed record StaffingResponse(IReadOnlyList Applicants); private sealed record ApplicantResponse(string Id); private sealed record SchoolDumpResponse( int Id, string Name, DateTime GameTime, bool Running, IReadOnlyList People, IReadOnlyList Now, IReadOnlyList Lessons); private sealed record DumpPersonResponse( string Id, string FullName, string? NodeId, IReadOnlyDictionary Needs); private sealed record DumpLessonResponse( string ClassId, string Subject, string TeacherId, string RoomId, int Day, int Period); }