Merge branch 'phase/39-school-owners'
ci / server (push) Failing after 3m51s
ci / client (push) Successful in 18s

This commit is contained in:
Leonid Pershin
2026-08-20 09:56:13 +03:00
34 changed files with 1107 additions and 267 deletions
+106 -37
View File
@@ -22,17 +22,17 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
Assert.Equal(20, welcome.TickRate);
Assert.Equal(6, welcome.MaxSchools);
Assert.Equal(2, welcome.MaxSchools);
}
[Fact]
public async Task ChangingSpeed_DoesNotResumeAPausedSchool()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пауза и скорость", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
@@ -52,7 +52,7 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task NoSchoolOpen_MeansNoClockFramesButTheCalendarKeepsRunning()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Живёт сама", StartDate);
@@ -72,11 +72,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task OpeningASchool_StreamsItsClock()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Ход времени", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
var first = await ReceiveClockAsync(socket);
Assert.Equal(school.Id, first.SchoolId);
@@ -93,11 +93,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task OpeningASchool_SendsAMapSnapshot()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Снимок карты", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Equal(school.Id, snapshot.SchoolId);
@@ -116,11 +116,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task OpeningASchool_LabelsTheSnapshotInTheHelloLocale()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "English snapshot", StartDate);
using var socket = await OpenSchoolAsync(school.Id, ProtocolConstants.LocaleEnglish);
using var socket = await OpenSchoolAsync(school.Id, client, ProtocolConstants.LocaleEnglish);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Equal("Principal's office", Assert.Single(snapshot.Nodes, node => node.Id == "principals-office").Name);
@@ -129,11 +129,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task OpeningASchool_WithACustomMap_ReturnsThatLayout()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateWithMapAsync(client, "Упрощённая", StartDate, SchoolApiTests.SimpleCustomMap);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1");
@@ -144,14 +144,14 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task ReloadFromDisk_RestoresACustomMap()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateWithMapAsync(client, "Карта с диска", StartDate, SchoolApiTests.SimpleCustomMap);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Contains(snapshot.Nodes, node => node.Id == "office");
@@ -161,12 +161,12 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnPresenceNotTheSnapshot()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
var school = await SchoolApiTests.CreateAsync(client, "Кто где сейчас", start);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
Assert.Contains(snapshot.Nodes, node => node.Id == "classroom-101");
@@ -224,12 +224,12 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task SkipEmpty_DuringWorkHours_IsIgnored()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
var school = await SchoolApiTests.CreateAsync(client, "Пропуск в учебное время", start);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
@@ -247,11 +247,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task Pausing_FreezesTheClock()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пауза", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
@@ -267,11 +267,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task SpeedIndex_ChangesHowFastTheCalendarMoves()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Быстрая", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
@@ -288,11 +288,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task LeavingASchool_KeepsItsClockRunning()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Выход", StartDate);
using (var socket = await OpenSchoolAsync(school.Id))
using (var socket = await OpenSchoolAsync(school.Id, client))
{
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer => ProtocolCodec.WriteCloseSchool(buffer));
@@ -309,11 +309,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task APausedSchool_StaysPausedAfterLeaving()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Оставлена на паузе", StartDate);
using (var socket = await OpenSchoolAsync(school.Id))
using (var socket = await OpenSchoolAsync(school.Id, client))
{
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
@@ -333,11 +333,11 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task DeletingTheOpenSchool_TellsTheClient()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Исчезнет", StartDate);
using var socket = await OpenSchoolAsync(school.Id);
using var socket = await OpenSchoolAsync(school.Id, client);
await ReceiveClockAsync(socket);
using var deleted = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
@@ -365,12 +365,12 @@ public class GameSocketTests(AppHostFixture fixture)
[Fact]
public async Task PausingOneSchool_DoesNotStopAnother()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var paused = await SchoolApiTests.CreateAsync(client, "На паузе", StartDate);
var running = await SchoolApiTests.CreateAsync(client, "Идёт дальше", StartDate);
using var socket = await OpenSchoolAsync(paused.Id);
using var socket = await OpenSchoolAsync(paused.Id, client);
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
@@ -387,14 +387,47 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.True(runningLater.GameTime > running.GameTime, "Pausing one school stopped the other.");
}
[Fact]
public async Task Guest_SetRunning_DoesNotChangeOwnerRunning()
{
using var ownerClient = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "GuestClockOwner");
await SchoolApiTests.WipeAllSavesAsync(ownerClient);
var school = await SchoolApiTests.CreateAsync(ownerClient, "Гостевые часы", StartDate);
var guestCookie = await SchoolApiTests.LoginAndGetCookieAsync(
fixture.App.CreateHttpClient("server"),
"GuestClockViewer");
using var socket = new ClientWebSocket();
socket.Options.SetRequestHeader("Cookie", guestCookie);
var http = fixture.App.GetEndpoint("server", "http");
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, ProtocolConstants.LocaleRussian)));
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(school.Id)));
_ = await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
var ownerView = await FindAsync(ownerClient, school.Id);
Assert.True(ownerView.Running);
using var delete = await ownerClient.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
delete.EnsureSuccessStatusCode();
}
[Fact]
public async Task ReloadFromDisk_RestoresAPausedClock()
{
using var client = fixture.App.CreateHttpClient("server");
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Снимок паузы", StartDate);
using (var socket = await OpenSchoolAsync(school.Id))
using (var socket = await OpenSchoolAsync(school.Id, client))
{
await ReceiveClockAsync(socket);
await SendAsync(socket, buffer =>
@@ -479,21 +512,57 @@ public class GameSocketTests(AppHostFixture fixture)
{
var state = await SchoolApiTests.GetSchoolsAsync(client);
var school = state.Schools.SingleOrDefault(candidate => candidate.Id == schoolId);
if (school is not null)
{
return school;
}
Assert.NotNull(school);
return school;
var other = state.Others.SingleOrDefault(candidate => candidate.Id == schoolId);
Assert.NotNull(other);
return new SchoolApiTests.SchoolResponse(
other.Id,
other.Name,
other.GameTime,
other.Running,
other.SpeedIndex,
other.Seed,
Mine: false,
other.ModIds);
}
private async Task<ClientWebSocket> OpenSchoolAsync(int schoolId, byte locale = ProtocolConstants.LocaleRussian)
private async Task<HttpClient> CreateOwnerHttpClientAsync()
{
using var httpClient = fixture.App.CreateHttpClient("server");
var socket = await ConnectAsync(httpClient, locale);
using var template = fixture.App.CreateHttpClient("server");
return await SchoolApiTests.CreateIsolatedClientAsync(template, SchoolApiTests.TestUserName);
}
private async Task<ClientWebSocket> OpenSchoolAsync(
int schoolId,
HttpClient ownerClient,
byte locale = ProtocolConstants.LocaleRussian)
{
var cookie = ownerClient.DefaultRequestHeaders.TryGetValues("Cookie", out var values)
? values.First()
: await SchoolApiTests.WebSocketCookieAsync(ownerClient);
var socket = await ConnectWithCookieAsync(cookie, locale);
await ReceiveUntilAsync(socket, MessageType.ServerWelcome);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(schoolId)));
return socket;
}
private async Task<ClientWebSocket> ConnectWithCookieAsync(string cookie, byte locale = ProtocolConstants.LocaleRussian)
{
var socket = new ClientWebSocket();
socket.Options.SetRequestHeader("Cookie", cookie);
var http = fixture.App.GetEndpoint("server", "http");
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, locale)));
return socket;
}
private async Task<ClientWebSocket> ConnectAsync(byte locale = ProtocolConstants.LocaleRussian)
{
using var httpClient = fixture.App.CreateHttpClient("server");
@@ -1,103 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class GoldenSaveTests(AppHostFixture fixture)
{
[Fact]
public async Task CurrentFormatSave_LoadsTheSamePeopleAndTime()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
await InstallAsync(client, "current");
var state = await SchoolApiTests.GetSchoolsAsync(client);
var school = Assert.Single(state.Schools);
Assert.Equal(1, school.Id);
Assert.Equal(1, school.Seed);
Assert.Equal("Золотая", school.Name);
Assert.Equal(new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), school.GameTime);
Assert.False(school.Running);
var people = await PeopleAsync(client, school.Id);
Assert.Equal(ExpectedNames(), people.Select(row => row.FullName).OrderBy(name => name, StringComparer.Ordinal).ToArray());
Assert.Contains(people, person => person.Roles.Contains("student"));
}
[Fact]
public async Task SaveWithoutNativeLanguage_LoadsWithoutReshuffling()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
await InstallAsync(client, "legacy-no-native");
var state = await SchoolApiTests.GetSchoolsAsync(client);
var school = Assert.Single(state.Schools);
Assert.Equal(1, school.Id);
Assert.Equal(1, school.Seed);
Assert.Equal("Золотая", school.Name);
var people = await PeopleAsync(client, school.Id);
Assert.Equal(ExpectedNames(), people.Select(row => row.FullName).OrderBy(name => name, StringComparer.Ordinal).ToArray());
}
private async Task InstallAsync(HttpClient client, string folder)
{
var directory = await SavesDirectoryAsync(client);
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);
}
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.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 async Task<IReadOnlyList<PersonRow>> PeopleAsync(HttpClient client, int schoolId)
{
var page = await client.GetFromJsonAsync<PeoplePage>(
$"/api/schools/{schoolId}/people?pageSize=100",
TestContext.Current.CancellationToken);
Assert.NotNull(page);
return page.People
.OrderBy(row => row.Id, StringComparer.Ordinal)
.ToArray();
}
private static string[] ExpectedNames()
{
var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "golden", "current", "1.people.json"));
using var document = JsonDocument.Parse(json);
return document.RootElement.GetProperty("people")
.EnumerateArray()
.Select(person =>
{
var name = person.GetProperty("name");
var surname = name.GetProperty("surname").GetString() ?? "";
var given = name.GetProperty("given").GetString() ?? "";
var patronymic = name.GetProperty("patronymic").GetString() ?? "";
return string.Join(' ', new[] { surname, given, patronymic }.Where(part => part.Length > 0));
})
.OrderBy(full => full, StringComparer.Ordinal)
.ToArray();
}
private sealed record SavesDirectoryResponse(string Path);
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
private sealed record PersonRow(string Id, string FullName, IReadOnlyList<string> Roles);
}
+127 -10
View File
@@ -19,7 +19,8 @@ public class SchoolApiTests(AppHostFixture fixture)
var state = await GetSchoolsAsync(client);
Assert.Equal(6, state.MaxSchools);
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
@@ -493,7 +494,7 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.NotNull(status);
Assert.Equal(20, status.TickRate);
Assert.Equal(6, status.MaxSchools);
Assert.Equal(2, status.MaxSchools);
Assert.True(status.Tick > 0, "The loop should have ticked by now.");
}
@@ -526,6 +527,63 @@ public class SchoolApiTests(AppHostFixture fixture)
_ = await LoginAndGetCookieAsync(client, userName);
}
internal static async Task<HttpClient> CreateIsolatedClientAsync(HttpClient template, string userName)
{
ArgumentNullException.ThrowIfNull(template.BaseAddress);
var login = new HttpClient { BaseAddress = template.BaseAddress };
try
{
var cookie = await LoginAndGetCookieAsync(login, userName);
var handler = new HttpClientHandler { UseCookies = false };
var client = new HttpClient(handler) { BaseAddress = template.BaseAddress };
client.DefaultRequestHeaders.Add("Cookie", cookie);
return client;
}
finally
{
login.Dispose();
}
}
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> WebSocketCookieAsync(HttpClient client, string userName = TestUserName)
{
if (client.DefaultRequestHeaders.TryGetValues("Cookie", out var values))
{
var cookie = values.First();
if (!string.IsNullOrWhiteSpace(cookie))
{
return cookie;
}
}
ArgumentNullException.ThrowIfNull(client.BaseAddress);
var login = new HttpClient { BaseAddress = client.BaseAddress };
try
{
return await LoginAndGetCookieAsync(login, userName);
}
finally
{
login.Dispose();
}
}
internal static async Task<string> LoginAndGetCookieAsync(HttpClient client, string userName = TestUserName)
{
using var first = await client.PostAsJsonAsync(
@@ -575,15 +633,60 @@ public class SchoolApiTests(AppHostFixture fixture)
}
internal static async Task ResetAsync(HttpClient client)
{
await WipeAllSavesAsync(client);
}
internal static async Task WipeAllSavesAsync(HttpClient client)
{
await LoginAsync(client);
var state = await GetSchoolsAsync(client);
foreach (var school in state.Schools)
for (var pass = 0; pass < 8; pass++)
{
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var state = await GetSchoolsAsync(client);
if (state.Schools.Count == 0 && state.Others.Count == 0)
{
return;
}
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)
{
HttpClient? ownedClient = null;
var deleter = client;
if (!string.IsNullOrWhiteSpace(other.Owner))
{
ownedClient = await CreateIsolatedClientAsync(client, other.Owner);
deleter = ownedClient;
}
try
{
using var response = await deleter.DeleteAsync($"/api/schools/{other.Id}", TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Forbidden)
{
continue;
}
response.EnsureSuccessStatusCode();
}
finally
{
ownedClient?.Dispose();
}
}
}
var left = await GetSchoolsAsync(client);
if (left.Schools.Count > 0 || left.Others.Count > 0)
{
throw new InvalidOperationException(
$"Could not wipe saves: {left.Schools.Count} own and {left.Others.Count} other schools remain.");
}
}
@@ -649,7 +752,7 @@ public class SchoolApiTests(AppHostFixture fixture)
return save;
}
private static async Task<string> SavesDirectoryAsync(HttpClient client)
internal static async Task<string> SavesDirectoryAsync(HttpClient client)
{
var payload = await client.GetFromJsonAsync<PathResponse>(
"/api/dev/saves-directory",
@@ -663,6 +766,7 @@ public class SchoolApiTests(AppHostFixture fixture)
string? CountryId,
string? ClimatePresetId,
string? NativeLanguage,
string? Owner,
SwarmUiSaveFile? PortraitSettings);
private sealed record SwarmUiSaveFile(string? ActivePresetId, IReadOnlyList<SwarmUiPresetSave>? Presets);
@@ -681,7 +785,7 @@ public class SchoolApiTests(AppHostFixture fixture)
links = new[] { new { a = "yard", b = "office" } },
};
private static Task<HttpResponseMessage> PostAsync(
internal static Task<HttpResponseMessage> PostAsync(
HttpClient client,
string name,
DateTime startDate,
@@ -704,14 +808,27 @@ public class SchoolApiTests(AppHostFixture fixture)
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<SchoolResponse> Schools,
IReadOnlyList<OtherSchoolResponse> Others);
private sealed record RandomNameResponse(string Name);
@@ -0,0 +1,211 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class SchoolOwnerApiTests(AppHostFixture fixture)
{
private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task TwoUsers_EachGetsTwoSchools_ThirdOwnIsLimitReached_OthersListForeignSchools()
{
using var wipe = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(wipe);
var alice = await CreateUserAsync("OwnerAlice");
var bob = await CreateUserAsync("OwnerBob");
await ResetUserAsync(alice);
for (var i = 0; i < 2; i++)
{
await SchoolApiTests.CreateAsync(alice, $"Alice {i + 1}", Start);
}
for (var i = 0; i < 2; i++)
{
await SchoolApiTests.CreateAsync(bob, $"Bob {i + 1}", Start);
}
using var third = await SchoolApiTests.PostAsync(alice, "Alice extra", Start);
Assert.Equal(HttpStatusCode.Conflict, third.StatusCode);
Assert.Equal("school-limit-reached", await SchoolApiTests.ProblemCodeAsync(third));
var aliceList = await SchoolApiTests.GetSchoolsAsync(alice);
Assert.Equal(2, aliceList.Schools.Count);
Assert.All(aliceList.Schools, school => Assert.True(school.Mine));
Assert.Equal(2, aliceList.Others.Count);
Assert.All(aliceList.Others, other => Assert.Equal("OwnerBob", other.Owner));
Assert.DoesNotContain(aliceList.Schools, school => school.Name.StartsWith("Bob", StringComparison.Ordinal));
foreach (var client in new[] { alice, bob })
{
client.Dispose();
}
using var cleanup = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(cleanup);
}
[Fact]
public async Task EighthSchool_OnFullServer_ReturnsServerFull()
{
using var wipe = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(wipe);
var users = new[] { "SrvA", "SrvB", "SrvC", "SrvD" };
var clients = new List<HttpClient>();
try
{
for (var u = 0; u < users.Length; u++)
{
var client = await CreateUserAsync(users[u]);
clients.Add(client);
var count = u < 3 ? 2 : 1;
for (var i = 0; i < count; i++)
{
await SchoolApiTests.CreateAsync(client, $"{users[u]}-{i}", Start);
}
}
var spare = await CreateUserAsync("SrvSpare");
using var response = await SchoolApiTests.PostAsync(spare, "One too many", Start);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("server-full", await SchoolApiTests.ProblemCodeAsync(response));
}
finally
{
foreach (var client in clients)
{
client.Dispose();
}
using var cleanup = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(cleanup);
}
}
[Fact]
public async Task Guest_Hire_IsForbidden()
{
using var wipe = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(wipe);
var owner = await CreateUserAsync("HireOwner");
var guest = await CreateUserAsync("HireGuest");
await ResetUserAsync(owner);
var school = await SchoolApiTests.CreateAsync(owner, "Чужой найм", Start);
using var staffing = await guest.GetAsync($"/api/schools/{school.Id}/staffing", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, staffing.StatusCode);
Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(staffing));
using var hire = await guest.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/hire",
new { personId = "a0.p0", position = "Teacher" },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, hire.StatusCode);
Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(hire));
owner.Dispose();
guest.Dispose();
using var cleanup = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(cleanup);
}
[Fact]
public async Task OwnerlessSave_AppearsInOthers_AndCanBeDeletedByAnotherUser()
{
using var wipe = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(wipe);
var first = await CreateUserAsync("SaveAlpha");
var created = await SchoolApiTests.CreateAsync(first, "Бесхозная", Start);
var directory = await SavesDirectoryAsync(first);
var saveFiles = new[]
{
$"{created.Id}.json",
$"{created.Id}.people.json",
$"{created.Id}.timetable.json",
};
var copies = saveFiles
.Select(name => Path.Combine(directory, name))
.Where(File.Exists)
.ToDictionary(path => path, File.ReadAllBytes);
await SchoolApiTests.WipeAllSavesAsync(first);
foreach (var (copiedPath, bytes) in copies)
{
File.WriteAllBytes(copiedPath, bytes);
}
var schoolSavePath = Path.Combine(directory, $"{created.Id}.json");
var node = JsonNode.Parse(File.ReadAllText(schoolSavePath))
?? throw new InvalidOperationException("School save parsed to nothing.");
node.AsObject().Remove("owner");
File.WriteAllText(
schoolSavePath,
node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
using var reload = await first.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var firstList = await SchoolApiTests.GetSchoolsAsync(first);
Assert.Empty(firstList.Schools);
var orphan = Assert.Single(firstList.Others, other => other.Name == "Бесхозная");
Assert.Null(orphan.Owner);
var second = await CreateUserAsync("SaveBeta");
using var delete = await second.DeleteAsync($"/api/schools/{orphan.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
var after = await SchoolApiTests.GetSchoolsAsync(second);
Assert.DoesNotContain(after.Others, other => other.Id == orphan.Id);
first.Dispose();
second.Dispose();
using var cleanup = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(cleanup);
}
private async Task<HttpClient> CreateUserAsync(string userName) =>
await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, userName);
private static async Task ResetUserAsync(HttpClient client)
{
await SchoolApiTests.LoginAsync(client);
var state = await SchoolApiTests.GetSchoolsAsync(client);
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();
}
}
private async Task ResetAllAsync()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(client);
}
private static async Task<string> SavesDirectoryAsync(HttpClient client) =>
await SchoolApiTests.SavesDirectoryAsync(client);
}
+10 -3
View File
@@ -19,14 +19,21 @@ public class SchoolSeedTests(AppHostFixture fixture)
var first = await SchoolApiTests.CreateAsync(client, "Сид одинаковый А", Start, seed: 42);
var second = await SchoolApiTests.CreateAsync(client, "Сид одинаковый Б", Start, seed: 42);
var other = await SchoolApiTests.CreateAsync(client, "Сид другой", Start, seed: 7);
var firstYearFive = await YearFiveAsync(client, first.Id);
var secondYearFive = await YearFiveAsync(client, second.Id);
var otherYearFive = await YearFiveAsync(client, other.Id);
Assert.Equal(firstYearFive, secondYearFive);
Assert.NotEqual(firstYearFive, otherYearFive);
using (var delete = await client.DeleteAsync($"/api/schools/{first.Id}", TestContext.Current.CancellationToken))
{
delete.EnsureSuccessStatusCode();
}
var other = await SchoolApiTests.CreateAsync(client, "Сид другой", Start, seed: 7);
var otherYearFive = await YearFiveAsync(client, other.Id);
Assert.NotEqual(secondYearFive, otherYearFive);
}
[Fact]
@@ -26,7 +26,16 @@ public class ServiceabilityTests(AppHostFixture fixture)
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
Assert.Empty((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
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
@@ -54,7 +63,16 @@ public class ServiceabilityTests(AppHostFixture fixture)
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
Assert.Empty((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
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
@@ -65,29 +83,6 @@ public class ServiceabilityTests(AppHostFixture fixture)
}
}
[Fact]
public async Task CurrentFormatSave_LoadsAsBefore()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var directory = await SavesDirectoryAsync(client);
try
{
InstallGolden(directory, "current");
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var school = Assert.Single((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
Assert.Equal(1, school.Id);
Assert.Equal("Золотая", school.Name);
}
finally
{
await SchoolApiTests.ResetAsync(client);
}
}
[Fact]
public async Task Dump_ReturnsPeopleNodesAndTimetable()
{