using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.Admin; using RadicadeServer.DB; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Auth.Cached; using RadicadeServer.RecNet.Enums.Players; using Xunit; namespace RadicadeServer.Tests; /// /// The players list sends joined_at and last_online_at so the panel can render them in the /// admin's own time zone. SQL Server hands a datetime2 back with Kind=Unspecified, and /// System.Text.Json writes such a value with no trailing "Z"; a browser parses a zone-less ISO /// string as already-local time, so an offline player's "Last Online" sat one admin time-zone /// offset away from the truth - it looked hours into the future. Only the online branch carried /// the marker, because there the value is DateTime.UtcNow. These tests pin the instants down /// exactly: the fix is the marker, never a shift of the clock itself. /// public class AdminPanelPlayerTimesTests { private const string Secret = "test-bot-secret"; private const long Player = 2; /// Unspecified is what SQL Server gives back for a datetime2, so that is what the fixtures store. private static readonly DateTime SignedUp = new(2026, 7, 29, 0, 56, 0, DateTimeKind.Unspecified); private static readonly DateTime LastLogin = new(2026, 9, 30, 21, 38, 0, DateTimeKind.Unspecified); private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); /// A database holding one player, with or without the cached login row that records their last sign-in. private static async Task WithPlayer(bool withLogin) { var db = NewDb(); db.Profiles.Add(new Profile { accountId = Player, username = "coach", displayName = "Coach", profileImage = "", createdAt = SignedUp }); if (withLogin) { db.CachedLogins.Add(new CachedLogin { accountId = Player, platform = Platforms.Steam, platformId = "76561198000000000", lastLoginTime = LastLogin }); } await db.SaveChangesAsync(); return db; } /// The two timestamps exactly as the panel's JSON.parse receives them. private static async Task<(string Joined, string LastOnline)> PlayerTimes(RecDBContext db) { var context = new DefaultHttpContext(); context.Request.QueryString = new QueryString("?key=" + Secret); var api = new AdminPanelAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } }; var original = DBConfig.AdminApiKey; DBConfig.AdminApiKey = Secret; try { var ok = Assert.IsType(await api.List("players", Secret, null, null, null, true)); // Same writer the panel is served by, so this is the wire text the browser sees // rather than a stand-in for it. using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); var row = document.RootElement[0]; return (row.GetProperty("joined_at").GetString()!, row.GetProperty("last_online_at").GetString()!); } finally { DBConfig.AdminApiKey = original; } } [Fact] public async Task AnOfflinePlayersLastOnlineIsMarkedAsUtc() { var db = await WithPlayer(withLogin: true); var (_, lastOnline) = await PlayerTimes(db); // 21:38 UTC is the moment they signed out, and the panel turns that into whatever o'clock // the admin's own zone reads it as. Without the "Z" the browser read it as 21:38 local. Assert.Equal("2026-09-30T21:38:00Z", lastOnline); } [Fact] public async Task TheAccountAgeIsMarkedAsUtcToo() { var db = await WithPlayer(withLogin: true); var (joined, _) = await PlayerTimes(db); Assert.Equal("2026-07-29T00:56:00Z", joined); } [Fact] public async Task APlayerWhoNeverLoggedInFallsBackToTheirAccountAge() { var db = await WithPlayer(withLogin: false); var (joined, lastOnline) = await PlayerTimes(db); // No cached login row means the profile's own createdAt is the newest thing known about them. Assert.Equal("2026-07-29T00:56:00Z", joined); Assert.Equal("2026-07-29T00:56:00Z", lastOnline); } }