WIP phase 39: school owners, my/others menu, guest restrictions.

This commit is contained in:
Leonid Pershin
2026-08-20 07:58:12 +03:00
parent 40929aa3ce
commit 26be7c87f8
27 changed files with 919 additions and 81 deletions
+34 -1
View File
@@ -22,7 +22,7 @@ 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]
@@ -387,6 +387,39 @@ 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()
{
+91 -6
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,21 @@ public class SchoolApiTests(AppHostFixture fixture)
_ = 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(
@@ -585,6 +601,62 @@ public class SchoolApiTests(AppHostFixture fixture)
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
foreach (var school in state.Others)
{
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Forbidden)
{
continue;
}
response.EnsureSuccessStatusCode();
}
}
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)
@@ -649,7 +721,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",
@@ -659,7 +731,7 @@ public class SchoolApiTests(AppHostFixture fixture)
return payload.Path;
}
private sealed record SchoolSaveFile(string? CountryId, string? ClimatePresetId, string? NativeLanguage);
private sealed record SchoolSaveFile(string? CountryId, string? ClimatePresetId, string? NativeLanguage, string? Owner);
internal static readonly object SimpleCustomMap = new
{
@@ -673,7 +745,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,
@@ -696,14 +768,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,164 @@
using System.Net.Http.Json;
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));
}
[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();
}
}
}
[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));
}
[Fact]
public async Task OwnerlessSave_AppearsInOthers_AndCanBeDeletedByAnotherUser()
{
using var wipe = fixture.App.CreateHttpClient("server");
await SchoolApiTests.WipeAllSavesAsync(wipe);
var first = await CreateUserAsync("SaveAlpha");
var directory = await SavesDirectoryAsync(first);
var golden = Path.Combine(AppContext.BaseDirectory, "golden", "current");
foreach (var file in Directory.EnumerateFiles(golden))
{
File.Copy(file, Path.Combine(directory, Path.GetFileName(file)), overwrite: 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);
}
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);
}