Files
Radicade/ServerTests/AdminAnnouncementTests.cs
2026-10-16 23:33:13 -07:00

150 lines
5.5 KiB
C#

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;
/// <summary>
/// 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&lt;long&gt;() 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.
/// </summary>
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<T> AsAdmin<T>(Func<Task<T>> 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<JsonObject> SaveAsync(AdminPanelAPI api, string rowJson)
=> await AsAdmin(async () =>
{
var ok = Assert.IsType<OkObjectResult>(await api.Insert("announcements", Secret, Payload(rowJson)));
var created = Assert.IsType<JsonObject>(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<long>());
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<long>() on the stored row is what used to throw.
Assert.Equal(6L, created["id"]!.GetValue<long>());
var stored = JsonNode.Parse(StoredValue(db))!.AsArray();
Assert.Equal(2, stored.Count);
Assert.Equal(5L, stored[0]!["id"]!.GetValue<long>());
Assert.Equal(6L, stored[1]!["id"]!.GetValue<long>());
// 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<OkObjectResult>(
await api.Delete("announcements", Secret, null, "9", null, null, null, null, null));
return Assert.IsType<List<object>>(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<long>());
Assert.Null(created["minutes"]);
Assert.Equal("Learn More", created["link_name"]!.GetValue<string>());
var feed = new AnnouncementAPI(db).GetAnnouncement();
Assert.Equal("Hi", Assert.Single(feed!).Title);
}
}