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;
///
/// 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.
///
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 SeedRoomAsync(RecDBContext db)
{
var room = new RoomData { Name = "Event Room" };
db.Rooms.Add(room);
await db.SaveChangesAsync();
return room.RoomId;
}
private static async Task CreateAsync(RecDBContext db, long roomId, string startTime, string endTime)
{
var body =
$$"""
{
"Name": "Time check",
"RoomId": {{roomId}},
"StartTime": "{{startTime}}",
"EndTime": "{{endTime}}"
}
""";
var created = Assert.IsType(await CreateController(db, body).CreateEventV2());
using var document = JsonDocument.Parse(JsonSerializer.Serialize(created.Value));
return document.RootElement.GetProperty("PlayerEvent").GetProperty("PlayerEventId").GetInt64();
}
/// An event time with no zone, the way the game posts it.
private static string WallClock(DateTime local) => local.ToString("yyyy-MM-dd'T'HH:mm:ss");
/// The same instant with the sub-second ticks the wall-clock form drops.
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(read.Result);
using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
var startTime = Assert.IsType(document.RootElement.GetProperty("StartTime").GetString());
var endTime = Assert.IsType(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)));
}
}