295 lines
12 KiB
C#
295 lines
12 KiB
C#
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Polls the dump while the clock is moving. A paused school would keep answering with empty
|
|
/// occupancy; fail immediately instead of waiting that out.
|
|
/// </summary>
|
|
private static async Task<SchoolDumpResponse> WaitUntilSomeoneIsOnANodeAsync(HttpClient client, int schoolId)
|
|
{
|
|
SchoolDumpResponse? dump = null;
|
|
for (var attempt = 0; attempt < 120; attempt++)
|
|
{
|
|
dump = await client.GetFromJsonAsync<SchoolDumpResponse>(
|
|
$"/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<StaffingResponse>(
|
|
$"/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<string> SavesDirectoryAsync(HttpClient client)
|
|
{
|
|
var payload = await client.GetFromJsonAsync<SavesDirectoryResponse>(
|
|
"/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<string?> ProblemCodeAsync(HttpResponseMessage response)
|
|
{
|
|
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
|
|
return problem?.Code;
|
|
}
|
|
|
|
private sealed record SavesDirectoryResponse(string Path);
|
|
|
|
private sealed record ProblemResponse(string? Code);
|
|
|
|
private sealed record StaffingResponse(IReadOnlyList<ApplicantResponse> Applicants);
|
|
|
|
private sealed record ApplicantResponse(string Id);
|
|
|
|
private sealed record SchoolDumpResponse(
|
|
int Id,
|
|
string Name,
|
|
DateTime GameTime,
|
|
bool Running,
|
|
IReadOnlyList<DumpPersonResponse> People,
|
|
IReadOnlyList<DumpLessonResponse> Now,
|
|
IReadOnlyList<DumpLessonResponse> Lessons);
|
|
|
|
private sealed record DumpPersonResponse(
|
|
string Id,
|
|
string FullName,
|
|
string? NodeId,
|
|
IReadOnlyDictionary<string, float> Needs);
|
|
|
|
private sealed record DumpLessonResponse(
|
|
string ClassId,
|
|
string Subject,
|
|
string TeacherId,
|
|
string RoomId,
|
|
int Day,
|
|
int Period);
|
|
}
|