mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
195 lines
8.3 KiB
C#
195 lines
8.3 KiB
C#
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using RadicadeServer.API.Events;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.RecNet.Events;
|
|
using RadicadeServer.RecNet.Rooms;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
/// <summary>
|
|
/// An event's start and end are the wall clock its creator picked, and the client posts them
|
|
/// with no zone on them. Treating that value as the instant itself made both this server and the
|
|
/// client file a fresh event as already finished: the Upcoming filters compare event windows to
|
|
/// UTC, and a zone-less timestamp on the wire is read as UTC by the client, so anything the
|
|
/// player scheduled "in twenty minutes" came back six hours in the past and was hidden. These
|
|
/// tests pin the conversion on the way in and the `Z` form on the way out.
|
|
/// </summary>
|
|
public class EventTimeTests
|
|
{
|
|
private const long CreatorId = 42;
|
|
|
|
private static RecDBContext CreateDb() => new(TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options);
|
|
|
|
private static EventsAPI CreateController(RecDBContext db, string? jsonBody = null)
|
|
{
|
|
var identity = new ClaimsIdentity("TestAuth");
|
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, CreatorId.ToString()));
|
|
|
|
var controller = new EventsAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(identity)
|
|
}
|
|
}
|
|
};
|
|
|
|
if (jsonBody != null)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(jsonBody);
|
|
controller.HttpContext.Request.Body = new MemoryStream(bytes);
|
|
controller.HttpContext.Request.ContentType = "application/json";
|
|
controller.HttpContext.Request.ContentLength = bytes.Length;
|
|
}
|
|
|
|
return controller;
|
|
}
|
|
|
|
private static async Task<long> SeedRoomAsync(RecDBContext db)
|
|
{
|
|
var room = new RoomData { Name = "Event Room" };
|
|
db.Rooms.Add(room);
|
|
await db.SaveChangesAsync();
|
|
return room.RoomId;
|
|
}
|
|
|
|
private static async Task<long> CreateAsync(RecDBContext db, long roomId, string startTime, string endTime)
|
|
{
|
|
var body =
|
|
$$"""
|
|
{
|
|
"Name": "Time check",
|
|
"RoomId": {{roomId}},
|
|
"StartTime": "{{startTime}}",
|
|
"EndTime": "{{endTime}}"
|
|
}
|
|
""";
|
|
var created = Assert.IsType<OkObjectResult>(await CreateController(db, body).CreateEventV2());
|
|
using var document = JsonDocument.Parse(JsonSerializer.Serialize(created.Value));
|
|
return document.RootElement.GetProperty("PlayerEvent").GetProperty("PlayerEventId").GetInt64();
|
|
}
|
|
|
|
/// <summary>An event time with no zone, the way the game posts it.</summary>
|
|
private static string WallClock(DateTime local) => local.ToString("yyyy-MM-dd'T'HH:mm:ss");
|
|
|
|
/// <summary>The same instant with the sub-second ticks the wall-clock form drops.</summary>
|
|
private static DateTime WholeSecond(DateTime value)
|
|
=> new(value.Ticks - value.Ticks % TimeSpan.TicksPerSecond, value.Kind);
|
|
|
|
[Fact]
|
|
public async Task APostedWallClockIsStoredAsTheUtcInstantItNames()
|
|
{
|
|
var db = CreateDb();
|
|
var roomId = await SeedRoomAsync(db);
|
|
var picked = new DateTime(2026, 10, 8, 20, 10, 0, DateTimeKind.Unspecified);
|
|
|
|
await CreateAsync(db, roomId, WallClock(picked), WallClock(picked.AddMinutes(30)));
|
|
|
|
var stored = await db.PlayerEvents.SingleAsync();
|
|
var expected = TimeZoneInfo.ConvertTimeToUtc(picked, TimeZoneInfo.Local);
|
|
Assert.Equal(expected, stored.StartTime);
|
|
Assert.Equal(expected.AddMinutes(30), stored.EndTime);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task APostedUtcTimestampIsHonouredAsTheInstantItNames()
|
|
{
|
|
// A client that starts sending real UTC must not be shifted by the server's zone.
|
|
var db = CreateDb();
|
|
var roomId = await SeedRoomAsync(db);
|
|
var instant = new DateTime(2026, 10, 9, 3, 10, 0, DateTimeKind.Utc);
|
|
|
|
await CreateAsync(db, roomId, instant.ToString("o"), instant.AddMinutes(30).ToString("o"));
|
|
|
|
var stored = await db.PlayerEvents.SingleAsync();
|
|
Assert.Equal(instant, stored.StartTime);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EventTimestampsGoOutAsUtcWithATrailingZ()
|
|
{
|
|
// Real RecNet serves every event time as `...Z`, and a zone-less value is read as UTC by
|
|
// the client - so the read shape, the write envelope and the notification frame must all
|
|
// carry the zone, or the client's own not-ended filter drops the event.
|
|
var db = CreateDb();
|
|
var roomId = await SeedRoomAsync(db);
|
|
var picked = WholeSecond(DateTime.Now.AddMinutes(20));
|
|
var eventId = await CreateAsync(db, roomId, WallClock(picked), WallClock(picked.AddHours(1)));
|
|
|
|
var controller = CreateController(db);
|
|
var read = await controller.GetEvent(eventId);
|
|
var ok = Assert.IsType<OkObjectResult>(read.Result);
|
|
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
|
|
|
|
var startTime = Assert.IsType<string>(document.RootElement.GetProperty("StartTime").GetString());
|
|
var endTime = Assert.IsType<string>(document.RootElement.GetProperty("EndTime").GetString());
|
|
|
|
Assert.EndsWith("Z", startTime);
|
|
Assert.EndsWith("Z", endTime);
|
|
Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(picked, TimeZoneInfo.Local),
|
|
DateTime.Parse(startTime, null, System.Globalization.DateTimeStyles.RoundtripKind));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnEventScheduledInTwentyMinutesIsUpcomingEverywhereTheClientLooks()
|
|
{
|
|
// The bug this guards: an event made "in twenty minutes" was stored as the wall clock and
|
|
// compared to UTC now, so it was six hours finished as far as every Upcoming-shaped query
|
|
// - and the client's own ended filter - were concerned.
|
|
var db = CreateDb();
|
|
var roomId = await SeedRoomAsync(db);
|
|
var picked = DateTime.Now.AddMinutes(20);
|
|
var eventId = await CreateAsync(db, roomId, WallClock(picked), WallClock(picked.AddHours(1)));
|
|
var controller = CreateController(db);
|
|
|
|
Assert.Contains(await controller.GetBrowseFeed(0, 50), e => e.PlayerEventId == eventId);
|
|
Assert.Contains(await controller.GetUpcomingEvents(), e => e.PlayerEventId == eventId);
|
|
Assert.Contains(await controller.SearchEvents(null, null), e => e.PlayerEventId == eventId);
|
|
|
|
// What the client itself checks: the timestamp parsed as UTC is still in the future.
|
|
var later = await controller.GetBrowseFeed(0, 50);
|
|
var onWire = later.Single(e => e.PlayerEventId == eventId).StartTime;
|
|
Assert.True(DateTimeOffset.Parse(EventClock.ToWire(onWire)) > DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnEventThatHasFinishedIsStillKeptOutOfTheUpcomingFeeds()
|
|
{
|
|
var db = CreateDb();
|
|
var roomId = await SeedRoomAsync(db);
|
|
var picked = DateTime.Now.AddHours(-4);
|
|
var eventId = await CreateAsync(db, roomId, WallClock(picked), WallClock(picked.AddHours(2)));
|
|
var controller = CreateController(db);
|
|
|
|
Assert.DoesNotContain(await controller.GetBrowseFeed(0, 50), e => e.PlayerEventId == eventId);
|
|
Assert.DoesNotContain(await controller.GetUpcomingEvents(), e => e.PlayerEventId == eventId);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheClockConvertsAWallClockAndPassesAUtcInstantThrough()
|
|
{
|
|
var wallClock = new DateTime(2026, 10, 8, 20, 10, 0, DateTimeKind.Unspecified);
|
|
|
|
Assert.Equal(TimeZoneInfo.ConvertTimeToUtc(wallClock, TimeZoneInfo.Local),
|
|
EventClock.FromPostedTime(wallClock));
|
|
|
|
var utc = new DateTime(2026, 10, 9, 3, 10, 0, DateTimeKind.Utc);
|
|
Assert.Equal(utc, EventClock.FromPostedTime(utc));
|
|
Assert.Equal("2026-10-09T03:10:00.0000000Z", EventClock.ToWire(utc));
|
|
|
|
// A value read back from the store carries no kind; it is already UTC and must not be
|
|
// converted a second time.
|
|
Assert.Equal("2026-10-09T03:10:00.0000000Z",
|
|
EventClock.ToWire(new DateTime(2026, 10, 9, 3, 10, 0, DateTimeKind.Unspecified)));
|
|
}
|
|
}
|