using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API; using RadicadeServer.Admin; using RadicadeServer.DB; using RadicadeServer.RecNet.Config; using Xunit; namespace RadicadeServer.Tests; /// /// The announcement feed lives as one JSON array in the ServerConfig "Announcement" row, and the /// panel's save re-derives the next id from the ids already in it. A row saved by an older panel can /// carry its id as a JSON string ("5"); reading that with GetValue<long>() threw, the route's /// catch answered HTTP 200 with an error body and no write, and the panel therefore looked like it /// had saved while nothing reached the client feed. These tests pin the tolerant read. /// public class AdminAnnouncementTests { private const string Secret = "test-bot-secret"; private static RecDBContext NewDb() => new( TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static JsonElement Payload(string json) => JsonDocument.Parse(json).RootElement; private static string StoredValue(RecDBContext db) => db.ServerConfig.First(x => x.Key == "Announcement").Value; private static async Task AsAdmin(Func> work) { var originalKey = DBConfig.AdminApiKey; var originalLogs = DBConfig.PersistentLogs; DBConfig.AdminApiKey = Secret; DBConfig.PersistentLogs = Path.Join(Path.GetTempPath(), "radicade-admin-announcement-tests-" + Guid.NewGuid().ToString("N")); try { return await work(); } finally { DBConfig.AdminApiKey = originalKey; DBConfig.PersistentLogs = originalLogs; } } private static async Task SaveAsync(AdminPanelAPI api, string rowJson) => await AsAdmin(async () => { var ok = Assert.IsType(await api.Insert("announcements", Secret, Payload(rowJson))); var created = Assert.IsType(ok.Value); Assert.False(created.ContainsKey("error"), created["error"]?.ToString()); return created; }); [Fact] public async Task ASaveOnAnEmptyConfigCreatesTheAnnouncementTheClientFeedServes() { var db = NewDb(); var created = await SaveAsync( new AdminPanelAPI(db), "{\"type\":\"Regular Announcement\",\"message\":\"Double XP all weekend.\",\"title\":\"Weekend event\"}"); Assert.Equal(1L, created["id"]!.GetValue()); var feed = new AnnouncementAPI(db).GetAnnouncement(); var served = Assert.Single(feed!); Assert.Equal(1L, served.AnnouncementId); Assert.Equal("Weekend event", served.Title); Assert.Equal("Double XP all weekend.", served.Body); Assert.True(served.IsMostRecent); } [Fact] public async Task ASaveStillLandsWhenAnExistingRowCarriesItsIdAsAString() { var db = NewDb(); db.ServerConfig.Add(new ServerConfigVal("Announcement", "[{\"id\":\"5\",\"type\":\"Regular Announcement\",\"message\":\"legacy row\",\"title\":\"Legacy\"}]")); await db.SaveChangesAsync(); var created = await SaveAsync( new AdminPanelAPI(db), "{\"type\":\"Regular Announcement\",\"message\":\"second\",\"title\":\"Second\"}"); // The save numbers the new row after the legacy one, and normalises that id on the way // through - GetValue() on the stored row is what used to throw. Assert.Equal(6L, created["id"]!.GetValue()); var stored = JsonNode.Parse(StoredValue(db))!.AsArray(); Assert.Equal(2, stored.Count); Assert.Equal(5L, stored[0]!["id"]!.GetValue()); Assert.Equal(6L, stored[1]!["id"]!.GetValue()); // Both rows are now servable: the legacy entry keeps the fields it was saved with. var feed = new AnnouncementAPI(db).GetAnnouncement(); Assert.Equal(2, feed!.Count); Assert.Equal("Second", feed[0].Title); } [Fact] public async Task ADeleteStillLandsWhenTheRowCarriesItsIdAsAString() { var db = NewDb(); db.ServerConfig.Add(new ServerConfigVal("Announcement", "[{\"id\":\"9\",\"type\":\"Regular Announcement\",\"message\":\"legacy row\"}]")); await db.SaveChangesAsync(); var api = new AdminPanelAPI(db); var removed = await AsAdmin(async () => { var ok = Assert.IsType( await api.Delete("announcements", Secret, null, "9", null, null, null, null, null)); return Assert.IsType>(ok.Value); }); Assert.Single(removed); Assert.Equal("[]", StoredValue(db)); } // The panel posts back every field of its row, so the boxes the admin left empty arrive as an // explicit JSON null rather than as missing keys. JsonElement.TryGetInt32 throws on a null token // (it only returns false for numbers it cannot represent), which made "minutes": null and // "link_type": null fail the whole save with "The requested operation requires an element of // type 'Number', but the target element has type 'Null'." [Fact] public async Task ASaveLandsWhenThePanelSendsItsEmptyFieldsAsNull() { var db = NewDb(); var created = await SaveAsync( new AdminPanelAPI(db), "{\"id\":null,\"type\":\"Regular Announcement\",\"message\":\"hi\",\"image\":null,\"minutes\":null," + "\"expires_at\":null,\"created_at\":null,\"title\":\"Hi\",\"link_name\":null,\"link_type\":null," + "\"link_uri\":null,\"AnnouncementId\":null,\"Body\":\"hi\",\"IsMostRecent\":null,\"Platform\":null}"); Assert.Equal(1L, created["id"]!.GetValue()); Assert.Null(created["minutes"]); Assert.Equal("Learn More", created["link_name"]!.GetValue()); var feed = new AnnouncementAPI(db).GetAnnouncement(); Assert.Equal("Hi", Assert.Single(feed!).Title); } }