Keep school owners and phase 41 opinions/portraits together; ResetAsync wipes all saves so shared AppHost tests stay isolated.
887 lines
34 KiB
C#
887 lines
34 KiB
C#
using System.Net.Http.Json;
|
||
|
||
namespace HSchool.AppHost.Tests;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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(2, state.MaxSchools);
|
||
Assert.Equal(7, state.MaxSchoolsTotal);
|
||
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");
|
||
await LoginAsync(client);
|
||
|
||
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<RandomNameResponse>(
|
||
"/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<ModsResponse>("/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<ModsResponse>("/api/mods?lang=ru", TestContext.Current.CancellationToken);
|
||
var en = await client.GetFromJsonAsync<ModsResponse>("/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<ModsResponse>("/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<ProblemResponse>(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<SchoolResponse>(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<CatalogResponse>("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
|
||
var en = await client.GetFromJsonAsync<CatalogResponse>("/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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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<object>(),
|
||
floors = Array.Empty<object>(),
|
||
rooms = Array.Empty<object>(),
|
||
links = Array.Empty<object>(),
|
||
},
|
||
},
|
||
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_WithAnUnknownCountry_IsRejected()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await ResetAsync(client);
|
||
|
||
using var response = await client.PostAsJsonAsync(
|
||
"/api/schools",
|
||
new
|
||
{
|
||
name = "Чужая страна",
|
||
startDate = ExpectedDefaultStart,
|
||
countryId = "NoSuchCountry",
|
||
},
|
||
TestContext.Current.CancellationToken);
|
||
|
||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||
Assert.Equal("unknown-country", await ProblemCodeAsync(response));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task CreateSchool_WithRussiaAndNoLanguage_PicksTheSameNativeAsTheSeed()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await ResetAsync(client);
|
||
const int seed = 42;
|
||
|
||
var first = await CreateWithCountryAsync(client, "Страна А", ExpectedDefaultStart, "Russia", seed);
|
||
var second = await CreateWithCountryAsync(client, "Страна Б", ExpectedDefaultStart, "Russia", seed);
|
||
|
||
var firstSave = await ReadSchoolSaveAsync(client, first.Id);
|
||
var secondSave = await ReadSchoolSaveAsync(client, second.Id);
|
||
Assert.Equal("Russia", firstSave.CountryId);
|
||
Assert.Equal(firstSave.NativeLanguage, secondSave.NativeLanguage);
|
||
Assert.Contains(firstSave.NativeLanguage, new[] { "RussianLanguage", "BelarusianLanguage", "UkrainianLanguage" });
|
||
}
|
||
|
||
[Fact]
|
||
public async Task CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await ResetAsync(client);
|
||
const int seed = 20260818;
|
||
|
||
var first = await CreateWithCountryAsync(client, "Климат А", ExpectedDefaultStart, "Russia", seed);
|
||
var second = await CreateWithCountryAsync(client, "Климат Б", ExpectedDefaultStart, "Russia", seed);
|
||
|
||
var firstSave = await ReadSchoolSaveAsync(client, first.Id);
|
||
var secondSave = await ReadSchoolSaveAsync(client, second.Id);
|
||
Assert.Equal(firstSave.ClimatePresetId, secondSave.ClimatePresetId);
|
||
Assert.Contains(firstSave.ClimatePresetId, new[] { "TemperateContinental", "ContinentalCold" });
|
||
}
|
||
|
||
[Fact]
|
||
public async Task CreateSchool_WithAnUnknownNativeLanguage_IsRejected()
|
||
{
|
||
using var client = fixture.App.CreateHttpClient("server");
|
||
await ResetAsync(client);
|
||
|
||
using var response = await client.PostAsJsonAsync(
|
||
"/api/schools",
|
||
new
|
||
{
|
||
name = "Чужой язык",
|
||
startDate = ExpectedDefaultStart,
|
||
countryId = "Russia",
|
||
nativeLanguage = "Klingon",
|
||
},
|
||
TestContext.Current.CancellationToken);
|
||
|
||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||
Assert.Equal("unknown-native-language", 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");
|
||
await LoginAsync(client);
|
||
|
||
var status = await client.GetFromJsonAsync<StatusResponse>("/api/status", TestContext.Current.CancellationToken);
|
||
|
||
Assert.NotNull(status);
|
||
Assert.Equal(20, status.TickRate);
|
||
Assert.Equal(2, 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 const string TestPassword = "test-alpha";
|
||
internal const string TestUserName = "TestPlayer";
|
||
|
||
internal static async Task LoginAsync(HttpClient client, string userName = TestUserName)
|
||
{
|
||
using var current = await client.GetAsync("/api/session", TestContext.Current.CancellationToken);
|
||
if (current.IsSuccessStatusCode)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_ = await LoginAndGetCookieAsync(client, userName);
|
||
}
|
||
|
||
internal static async Task<HttpClient> CreateIsolatedClientAsync(DistributedApplication app, string userName)
|
||
{
|
||
var login = app.CreateHttpClient("server");
|
||
var cookie = await LoginAndGetCookieAsync(login, userName);
|
||
login.Dispose();
|
||
|
||
var handler = new HttpClientHandler { UseCookies = false };
|
||
var client = new HttpClient(handler)
|
||
{
|
||
BaseAddress = new Uri(app.GetEndpoint("server", "http").ToString()),
|
||
};
|
||
client.DefaultRequestHeaders.Add("Cookie", cookie);
|
||
return client;
|
||
}
|
||
|
||
internal static async Task<string> LoginAndGetCookieAsync(HttpClient client, string userName = TestUserName)
|
||
{
|
||
using var first = await client.PostAsJsonAsync(
|
||
"/api/session",
|
||
new { password = TestPassword, userName },
|
||
TestContext.Current.CancellationToken);
|
||
|
||
if (first.IsSuccessStatusCode)
|
||
{
|
||
return ExtractSessionCookie(first);
|
||
}
|
||
|
||
if (first.StatusCode == HttpStatusCode.Conflict)
|
||
{
|
||
userName = $"T-{Guid.NewGuid():N}"[..10];
|
||
using var retry = await client.PostAsJsonAsync(
|
||
"/api/session",
|
||
new { password = TestPassword, userName },
|
||
TestContext.Current.CancellationToken);
|
||
retry.EnsureSuccessStatusCode();
|
||
return ExtractSessionCookie(retry);
|
||
}
|
||
|
||
first.EnsureSuccessStatusCode();
|
||
return ExtractSessionCookie(first);
|
||
}
|
||
|
||
internal static string ExtractSessionCookie(HttpResponseMessage response)
|
||
{
|
||
if (!response.Headers.TryGetValues("Set-Cookie", out var values))
|
||
{
|
||
throw new InvalidOperationException("Login did not return a session cookie.");
|
||
}
|
||
|
||
foreach (var value in values)
|
||
{
|
||
if (!value.StartsWith("hschool.session=", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var end = value.IndexOf(';');
|
||
return end >= 0 ? value[..end] : value;
|
||
}
|
||
|
||
throw new InvalidOperationException("Login did not return a session cookie.");
|
||
}
|
||
|
||
internal static async Task ResetAsync(HttpClient client)
|
||
{
|
||
await WipeAllSavesAsync(client);
|
||
}
|
||
|
||
internal static async Task WipeAllSavesAsync(HttpClient client)
|
||
{
|
||
await LoginAsync(client);
|
||
|
||
for (var pass = 0; pass < 4; pass++)
|
||
{
|
||
var state = await GetSchoolsAsync(client);
|
||
if (state.Schools.Count == 0 && state.Others.Count == 0)
|
||
{
|
||
break;
|
||
}
|
||
|
||
foreach (var school in state.Schools)
|
||
{
|
||
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
|
||
response.EnsureSuccessStatusCode();
|
||
}
|
||
|
||
foreach (var other in state.Others)
|
||
{
|
||
using var response = await client.DeleteAsync($"/api/schools/{other.Id}", TestContext.Current.CancellationToken);
|
||
if (response.StatusCode == HttpStatusCode.Forbidden)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
response.EnsureSuccessStatusCode();
|
||
}
|
||
}
|
||
|
||
var directory = await SavesDirectoryAsync(client);
|
||
foreach (var path in Directory.EnumerateFiles(directory))
|
||
{
|
||
File.Delete(path);
|
||
}
|
||
|
||
foreach (var path in Directory.EnumerateDirectories(directory))
|
||
{
|
||
Directory.Delete(path, recursive: true);
|
||
}
|
||
|
||
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||
reload.EnsureSuccessStatusCode();
|
||
}
|
||
|
||
internal static async Task<SchoolsResponse> GetSchoolsAsync(HttpClient client)
|
||
{
|
||
var state = await client.GetFromJsonAsync<SchoolsResponse>("/api/schools", TestContext.Current.CancellationToken);
|
||
Assert.NotNull(state);
|
||
return state;
|
||
}
|
||
|
||
internal static async Task<SchoolResponse> CreateAsync(
|
||
HttpClient client,
|
||
string name,
|
||
DateTime startDate,
|
||
int? seed = null)
|
||
{
|
||
using var response = await PostAsync(client, name, startDate, seed);
|
||
response.EnsureSuccessStatusCode();
|
||
|
||
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
|
||
Assert.NotNull(created);
|
||
return created;
|
||
}
|
||
|
||
internal static async Task<SchoolResponse> 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<SchoolResponse>(TestContext.Current.CancellationToken);
|
||
Assert.NotNull(created);
|
||
return created;
|
||
}
|
||
|
||
private static async Task<SchoolResponse> CreateWithCountryAsync(
|
||
HttpClient client,
|
||
string name,
|
||
DateTime startDate,
|
||
string countryId,
|
||
int seed)
|
||
{
|
||
using var response = await client.PostAsJsonAsync(
|
||
"/api/schools",
|
||
new { name, startDate, countryId, seed },
|
||
TestContext.Current.CancellationToken);
|
||
response.EnsureSuccessStatusCode();
|
||
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
|
||
Assert.NotNull(created);
|
||
return created;
|
||
}
|
||
|
||
private static async Task<SchoolSaveFile> ReadSchoolSaveAsync(HttpClient client, int id)
|
||
{
|
||
var directory = await SavesDirectoryAsync(client);
|
||
var json = await File.ReadAllTextAsync(Path.Combine(directory, $"{id}.json"), TestContext.Current.CancellationToken);
|
||
var save = System.Text.Json.JsonSerializer.Deserialize<SchoolSaveFile>(
|
||
json,
|
||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||
Assert.NotNull(save);
|
||
return save;
|
||
}
|
||
|
||
internal static async Task<string> SavesDirectoryAsync(HttpClient client)
|
||
{
|
||
var payload = await client.GetFromJsonAsync<PathResponse>(
|
||
"/api/dev/saves-directory",
|
||
TestContext.Current.CancellationToken);
|
||
Assert.NotNull(payload);
|
||
Assert.False(string.IsNullOrWhiteSpace(payload.Path));
|
||
return payload.Path;
|
||
}
|
||
|
||
private sealed record SchoolSaveFile(
|
||
string? CountryId,
|
||
string? ClimatePresetId,
|
||
string? NativeLanguage,
|
||
string? Owner,
|
||
SwarmUiSaveFile? PortraitSettings);
|
||
|
||
private sealed record SwarmUiSaveFile(string? ActivePresetId, IReadOnlyList<SwarmUiPresetSave>? Presets);
|
||
|
||
private sealed record SwarmUiPresetSave(string? Id, string? Model);
|
||
|
||
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<object>() },
|
||
},
|
||
links = new[] { new { a = "yard", b = "office" } },
|
||
};
|
||
|
||
internal static Task<HttpResponseMessage> PostAsync(
|
||
HttpClient client,
|
||
string name,
|
||
DateTime startDate,
|
||
int? seed = null) =>
|
||
client.PostAsJsonAsync(
|
||
"/api/schools",
|
||
seed is null ? (object)new { name, startDate } : new { name, startDate, seed },
|
||
TestContext.Current.CancellationToken);
|
||
|
||
internal static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
|
||
{
|
||
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
|
||
return problem?.Code;
|
||
}
|
||
|
||
internal sealed record SchoolResponse(
|
||
int Id,
|
||
string Name,
|
||
DateTime GameTime,
|
||
bool Running,
|
||
byte SpeedIndex,
|
||
int Seed,
|
||
bool Mine,
|
||
IReadOnlyList<string>? ModIds = null);
|
||
|
||
internal sealed record OtherSchoolResponse(
|
||
int Id,
|
||
string Name,
|
||
DateTime GameTime,
|
||
bool Running,
|
||
byte SpeedIndex,
|
||
int Seed,
|
||
string? Owner,
|
||
IReadOnlyList<string>? ModIds = null);
|
||
|
||
internal sealed record SchoolsResponse(
|
||
int MaxSchools,
|
||
int MaxSchoolsTotal,
|
||
DateTime DefaultStartDate,
|
||
double GameMinutesPerRealSecond,
|
||
int SchoolWeekDays,
|
||
IReadOnlyList<SchoolResponse> Schools,
|
||
IReadOnlyList<OtherSchoolResponse> Others);
|
||
|
||
private sealed record RandomNameResponse(string Name);
|
||
|
||
private sealed record ProblemResponse(string? Code, string? Missing = null);
|
||
|
||
private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
|
||
|
||
private sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
|
||
|
||
private sealed record ModInfoResponse(
|
||
string Id,
|
||
bool Required,
|
||
string Label,
|
||
string Version,
|
||
IReadOnlyList<string> Requires);
|
||
|
||
private sealed record PathResponse(string Path);
|
||
|
||
private static async Task<string> ModsDirectoryAsync(HttpClient client)
|
||
{
|
||
var payload = await client.GetFromJsonAsync<PathResponse>(
|
||
"/api/dev/mods-directory",
|
||
TestContext.Current.CancellationToken);
|
||
Assert.NotNull(payload);
|
||
Assert.False(string.IsNullOrWhiteSpace(payload.Path));
|
||
return payload.Path;
|
||
}
|
||
|
||
private sealed class TempPack : IDisposable
|
||
{
|
||
public TempPack(string modsRoot, string id, string? packJsonc = null)
|
||
{
|
||
Path = System.IO.Path.Combine(modsRoot, id);
|
||
if (Directory.Exists(Path))
|
||
{
|
||
Directory.Delete(Path, recursive: true);
|
||
}
|
||
|
||
Directory.CreateDirectory(Path);
|
||
if (packJsonc is not null)
|
||
{
|
||
File.WriteAllText(System.IO.Path.Combine(Path, "pack.jsonc"), packJsonc);
|
||
}
|
||
}
|
||
|
||
public string Path { get; }
|
||
|
||
public void Dispose()
|
||
{
|
||
if (Directory.Exists(Path))
|
||
{
|
||
Directory.Delete(Path, recursive: true);
|
||
}
|
||
}
|
||
}
|
||
|
||
private sealed record CatalogResponse(
|
||
IReadOnlyList<DefInfoResponse> Territories,
|
||
IReadOnlyList<DefInfoResponse> Buildings,
|
||
IReadOnlyList<DefInfoResponse> Floors,
|
||
IReadOnlyList<RoomInfoResponse> Rooms,
|
||
IReadOnlyList<DefInfoResponse> Things,
|
||
IReadOnlyList<CountryInfoResponse> Countries,
|
||
IReadOnlyList<SubjectInfoResponse> Subjects,
|
||
DayFrameResponse? DayFrame,
|
||
IReadOnlyList<HolidayInfoResponse> Holidays,
|
||
MapLayoutResponse DefaultMap);
|
||
|
||
private sealed record DefInfoResponse(string DefName, string Label);
|
||
|
||
private sealed record CountryInfoResponse(
|
||
string DefName,
|
||
string Label,
|
||
IReadOnlyList<DefInfoResponse> NativeLanguages,
|
||
IReadOnlyList<string> ClimatePresets);
|
||
|
||
private sealed record RoomInfoResponse(
|
||
string DefName,
|
||
string Label,
|
||
bool Homeroom,
|
||
string? SeatThing,
|
||
int DefaultSeats,
|
||
IReadOnlyList<RoomSlotResponse> Slots,
|
||
IReadOnlyList<string> 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);
|
||
}
|