using System.Reflection; using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Compatibility; using RadicadeServer.DB; using RadicadeServer.RecNet.Auth.Cached; using RadicadeServer.RecNet.Enums.Players; using RadicadeServer.API.Keepsakes; using RadicadeServer.RecNet.Keepsakes; using RadicadeServer.API.IncentivizedReferrals; using RadicadeServer.API.InfluencerPartnerProgram; using RadicadeServer.API.Progression.SubscriptionSeasons.V1; using RadicadeServer.API.Storefront; using RadicadeServer.API.Storefront.V1; using RadicadeServer.RoomServer; using Xunit; namespace RadicadeServer.Tests; public class StartupApiCompatibilityTests { [Fact] public void KeepsakeCategoriesReturnsCountedResults() { var previous = KeepsakeRoomConfig.Override; KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse("""{ "AllRooms": true }"""); try { var response = Assert.IsType(new KeepsakesAPI(null!).Categories()); var json = JsonSerializer.SerializeToElement(response.Value); // A COUNTED set, whatever it holds: a bare list fails the client's parser outright // ("expected:'{', actual:'['", logged as "Failed to get keepsake categories") and takes // the keepsake load down with it. The entries themselves are the real catalog now. Assert.Equal(JsonValueKind.Object, json.ValueKind); var results = json.GetProperty("Results"); Assert.Equal(JsonValueKind.Array, results.ValueKind); Assert.Equal(results.GetArrayLength(), json.GetProperty("TotalResults").GetInt32()); Assert.Equal(KeepsakeCatalog.Results().Length, results.GetArrayLength()); } finally { KeepsakeRoomConfig.Override = previous; } } /// /// The disabled state, route by route: no keepsake room configured means the whole feature is /// off (), and every answer a client could read a keepsake from has /// to say so - an empty counted catalog rather than a list, NO CONTENT for the room, no active /// event, and a placement refused instead of stored. /// [Fact] public async Task WithNoKeepsakeRoomsTheFeatureAnswersOffEverywhere() { var previousRooms = KeepsakeRoomConfig.Override; var previousFeature = KeepsakeFeature.Override; KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse(null); KeepsakeFeature.Override = null; try { var config = JsonSerializer.SerializeToElement( Assert.IsType(new KeepsakesAPI(null!).GlobalConfig()).Value); Assert.False(config.GetProperty("KeepsakeFeatureEnabled").GetBoolean()); Assert.Equal(0, config.GetProperty("KeepsakeRoomLimit").GetInt32()); var catalog = JsonSerializer.SerializeToElement( Assert.IsType(new KeepsakesAPI(null!).Categories()).Value); Assert.Equal(0, catalog.GetProperty("Results").GetArrayLength()); Assert.Equal(0, catalog.GetProperty("TotalResults").GetInt32()); Assert.IsType(await new KeepsakesAPI(null!).Room(25)); Assert.IsType(await new KeepsakesAPI(null!).Place(25)); } finally { KeepsakeRoomConfig.Override = previousRooms; KeepsakeFeature.Override = previousFeature; } } /// /// The emptied catalog is still a counted set rather than a bare list - the shape the endpoint /// has to keep even when there is nothing in it. /// [Fact] public void AnEmptyKeepsakeCatalogIsStillACountedSet() { var json = JsonSerializer.SerializeToElement(KeepsakeCatalog.Results(enabled: false)); Assert.Equal(JsonValueKind.Array, json.ValueKind); Assert.Equal(0, json.GetArrayLength()); } /// /// A room's keepsakes answer with NO body. The client reads any body here as keepsakes it /// has to resolve, so an empty list is not the safe answer for it - the absence of one is. /// A room listed in is the deliberate exception and answers /// with the room fields, because that answer is the only place its keepsake permission can /// come from - see KeepsakeRoomConfigTests. /// [Fact] public async Task RoomKeepsakesAnswerNoContent() { var previous = KeepsakeRoomConfig.Override; KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse(null); try { Assert.IsType(await new KeepsakesAPI(null!).Room(25)); } finally { KeepsakeRoomConfig.Override = previous; } } /// /// The active progression event is the event's ID and nothing else - a bare number, which is /// what the client's no-argument call returns (`MFBKDKPGMNP`). An array, an object or a /// whole record does not deserialize into a nullable long, and every one of those shapes has /// been answered here across these runs, each logged by the client as /// `Malformed Response: '`body`'`. /// [Fact] public void TheActiveProgressionEventIsTheIdTheRoomNames() { var previous = KeepsakeRoomConfig.Override; KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse( """{ "RoomIds": [25], "RoomListId": 7, "RoomLimit": 5, "EventId": 42, "EventName": "Test Event" }"""); try { var response = Assert.IsType( new CompatibilityAPI(null!).ActiveProgressionEvents()); var json = JsonSerializer.SerializeToElement(response.Value); Assert.Equal(JsonValueKind.Number, json.ValueKind); Assert.Equal(42, json.GetInt64()); } finally { KeepsakeRoomConfig.Override = previous; } } /// /// The caller's current subscription season is absent, not `{}`. The client's DTO for this route /// has six members (`Guid`, `string`, `string`, `DateTime`, `DateTime?`, `List<…>` - the /// 2023-09-12 reference's `ALGDPMHOPGG`), so an empty object is a body it cannot bind: the /// 2026-10-01 21:37 log's `Malformed Response: '{}'` is thrown right after this route is fetched. /// No season exists on this server, so "none" is answered at the HTTP level instead of inventing /// six member names and putting a season in front of the player. /// [Fact] public void TheCurrentSubscriptionSeasonIsAbsentRatherThanAnEmptyObject() { Assert.IsType(new SubscriptionSeasonsAPI().GetCurrentSeason()); var method = typeof(SubscriptionSeasonsAPI).GetMethod(nameof(SubscriptionSeasonsAPI.GetCurrentSeason))!; var template = Assert.Single(method.GetCustomAttributes()).Template; Assert.Equal("seasons/current", template); var classRoutes = typeof(SubscriptionSeasonsAPI).GetCustomAttributes() .Select(route => route.Template) .ToArray(); Assert.Contains("econ/api/subscriptionseasons/v1", classRoutes); } /// /// The event itself, by id: the record the client fetches once it has the id above, carrying /// the same one-list entry the room's own keepsake payload carries. The list is what the client /// applies to the room, so a room the event does not name would be overridden back to having no /// keepsake permission. /// [Fact] public void TheProgressionEventByIdCarriesItsKeepsakeRoomLists() { var previous = KeepsakeRoomConfig.Override; KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse( """{ "RoomIds": [25], "RoomListId": 7, "RoomLimit": 5, "EventId": 42, "EventName": "Test Event" }"""); try { var api = new CompatibilityAPI(null!); // The definition owns the room lists. The player record has a separate DTO. foreach (var (route, response) in new (string, IActionResult)[] { ("event", api.ProgressionEventDefinition(42)), ("event", api.ProgressionEvent(42)), }) { var json = JsonSerializer.SerializeToElement(Assert.IsType(response).Value); Assert.Equal(JsonValueKind.Object, json.ValueKind); Assert.Equal(route, json.GetProperty("_route").GetString()); Assert.Equal(42, json.GetProperty("ProgressionEventId").GetInt64()); Assert.Equal("Test Event", json.GetProperty("Name").GetString()); Assert.True(json.GetProperty("IsEnabled").GetBoolean()); // Running, or the client applies nothing. Assert.True(json.GetProperty("StartTime").GetDateTimeOffset() < DateTimeOffset.UtcNow); Assert.True(json.GetProperty("EndTime").GetDateTimeOffset() > DateTimeOffset.UtcNow); var list = Assert.Single(json.GetProperty("KeepsakeRoomLists").EnumerateArray()); Assert.Equal(7, list.GetProperty("KeepsakeRoomListId").GetInt64()); Assert.Equal(5, list.GetProperty("KeepsakeRoomLimit").GetInt32()); Assert.Equal( new long[] { 25 }, list.GetProperty("KeepsakeRooms").EnumerateArray().Select(e => e.GetProperty("RoomId").GetInt64()).ToArray()); } // The record also carries the definition nested, which is the other shape a record takes // in this API - an unknown member there is ignored, so carrying both costs nothing. var record = JsonSerializer.SerializeToElement( Assert.IsType(api.ProgressionEventRecord(42)).Value); Assert.Equal(0, record.GetProperty("Xp").GetInt32()); Assert.Equal(0, record.GetProperty("RewardsCollected").GetInt32()); Assert.Equal(JsonValueKind.Null, record.GetProperty("XpBoostLastPurchasedAt").ValueKind); Assert.False(record.TryGetProperty("ProgressionEvent", out _)); // An id this server does not have an event for is a 404, not a made-up record. Assert.IsType(api.ProgressionEvent(43)); Assert.IsType(api.ProgressionEventDefinition(43)); Assert.IsType(api.ProgressionEventRecord(43)); } finally { KeepsakeRoomConfig.Override = previous; } } /// /// With no keepsake room configured there is no event to report, and the endpoint answers the /// JSON literal `null` - the body its declared `long?` reads as "no event". The empty array it /// used to answer is logged by the client as `Malformed Response: '[]'` (2026-10-01 17:54 and /// 21:37 runs), which is a parse failure and not a "nothing running" answer, and a 404 is an /// empty body the client reports as a failed call it retries. /// [Fact] public void NoKeepsakeRoomsMeansNoActiveEvent() { var previous = KeepsakeRoomConfig.Override; KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse(null); try { var response = Assert.IsType( new CompatibilityAPI(null!).ActiveProgressionEvents()); Assert.Equal("null", response.Content); Assert.Equal("application/json", response.ContentType); Assert.True(response.StatusCode is null or 200); } finally { KeepsakeRoomConfig.Override = previous; } } [Fact] public void KeepsakeConfigUsesTheClientFieldNames() { var previous = KeepsakeRoomConfig.Override; KeepsakeRoomConfig.Override = KeepsakeRoomConfig.Parse("""{ "AllRooms": true }"""); try { var response = Assert.IsType(new KeepsakesAPI(null!).GlobalConfig()); var json = JsonSerializer.SerializeToElement(response.Value); // These are the names the client's parser reads. A config carrying anything else // leaves every switch at its default instead of the value that was sent. Assert.True(json.GetProperty("KeepsakeFeatureEnabled").GetBoolean()); Assert.Equal(10, json.GetProperty("KeepsakeRoomLimit").GetInt32()); Assert.False(json.GetProperty("SocialXpBoostEnabled").GetBoolean()); } finally { KeepsakeRoomConfig.Override = previous; } } /// /// A room's XP settings are a BARE two-key object - the room page parses this one /// directly, so an envelope around it would fail the same way the keepsake catalog does. /// A 404 here is what the client logs as `HTTP Error 404` as the room page opens, with the /// maker buttons on it never built. /// [Fact] public void RoomExperienceAnswersTheTwoKeysTheClientReads() { var response = Assert.IsType(new V2_RoomsAPI(null!).GetRoomExperience(25)); var json = JsonSerializer.SerializeToElement(response.Value); Assert.False(json.GetProperty("Enabled").GetBoolean()); Assert.Equal(1000, json.GetProperty("DailyLimit").GetInt32()); Assert.False(json.TryGetProperty("value", out _)); } /// /// The caller's per-room progress is an empty LIST: the client reads that as "no progress /// in this room", where a 404 is an HTTP error and stalls the room page it is loading. /// [Fact] public void RoomExperienceForPlayerAnswersAnEmptyList() { var response = Assert.IsType(new V2_RoomsAPI(null!).GetRoomExperienceForPlayer(25)); var json = JsonSerializer.SerializeToElement(response.Value); Assert.Equal(JsonValueKind.Array, json.ValueKind); Assert.Equal(0, json.GetArrayLength()); } /// /// A room's gift-drop shops are an empty LIST. The client asks for this route as a room /// page opens and re-asks when it 404s, which is what leaves the page's shop entries /// waiting while it logs an HTTP error. /// [Fact] public void RoomGiftDropShopsAnswerAnEmptyList() { var response = Assert.IsType(new RoomGiftDropShopAPI().GetRoomGiftDropShops(25)); var json = JsonSerializer.SerializeToElement(response.Value); Assert.Equal(JsonValueKind.Array, json.ValueKind); Assert.Equal(0, json.GetArrayLength()); } [Fact] public void UserSegmentCompatibilityRouteIsRegisteredAndReturnsTheThreeSegmentMembers() { var route = typeof(CommonQueryAPI).GetMethod(nameof(CommonQueryAPI.GetUserSegment))! .GetCustomAttributes() .Single(); Assert.Equal("/data/commonQuery/userSegment", route.Template); // The empty object this route used to answer is a `Malformed Response: '{}'` in the client // (no member to bind), so the shape under test is "an object with the segment members", // not merely "an object". var response = Assert.IsType(new CommonQueryAPI().GetUserSegment()); var json = JsonSerializer.SerializeToElement(response.Value); Assert.Equal(JsonValueKind.Object, json.ValueKind); Assert.Equal( new[] { "User_segment_algo_name", "User_segment_algo_version", "User_segment_value" }, json.EnumerateObject().Select(property => property.Name).ToArray()); } [Fact] public void PurchaseCampaignsReturnABareList() { // CompatibilityAPI now takes the database (it stores room comments), which this shape // check does not touch - a null context is enough for a route that reads no data. var response = Assert.IsType(new CompatibilityAPI(null!).CurrentPurchaseCampaigns()); var json = JsonSerializer.SerializeToElement(response.Value); // PurchaseCampaignManager holds these as List; the paged shape fails its list reader. Assert.Equal(JsonValueKind.Array, json.ValueKind); } [Theory] [InlineData(typeof(ReferralsAPI), "incentivizedreferrals", "GetReferrealProgress", "progress")] [InlineData(typeof(InfluencerPartnerProgramAPI), "influencerpartnerprogram", "GetInfluencers", "influencers")] public void EconomyStartupEndpointsExposeBothRoutePrefixes( Type controller, string resource, string method, string action) { var routes = controller.GetCustomAttributes() .SelectMany(prefix => controller.GetMethod(method)!.GetCustomAttributes() .Select(suffix => $"{prefix.Template}/{suffix.Template}")) .ToArray(); Assert.Contains($"api/{resource}/{action}", routes); Assert.Contains($"econ/api/{resource}/{action}", routes); Assert.Contains($"econ/api/{resource}/v1/{action}", routes); } [Fact] public void StorefrontCarouselExposesEconomyRoute() { var routes = typeof(StorefrontsV1).GetCustomAttributes() .SelectMany(prefix => typeof(StorefrontsV1).GetMethod("GetAdCarouselItems")! .GetCustomAttributes() .Select(suffix => $"{prefix.Template}/{suffix.Template}")) .ToArray(); Assert.Contains("api/storefronts/v1/adcarouselitems", routes); Assert.Contains("econ/api/storefronts/v1/adcarouselitems", routes); } [Fact] public async Task CachedLoginDeleteRemovesOnlyTheAuthenticatedPlatformLogin() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var db = new RecDBContext(options); db.CachedLogins.AddRange( new CachedLogin { accountId = 71, platform = Platforms.Steam, platformId = "steam-71" }, new CachedLogin { accountId = 71, platform = Platforms.Oculus, platformId = "quest-71" }, new CachedLogin { accountId = 72, platform = Platforms.Steam, platformId = "steam-71" }); await db.SaveChangesAsync(); var api = new CompatibilityAPI(db); var identity = new System.Security.Claims.ClaimsIdentity("test"); identity.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "71")); identity.AddClaim(new System.Security.Claims.Claim("rn.platid", "steam-71")); identity.AddClaim(new System.Security.Claims.Claim("rn.plat", ((int)Platforms.Steam).ToString())); api.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new System.Security.Claims.ClaimsPrincipal(identity) } }; Assert.IsType(await api.DeleteCurrentCachedLogin()); Assert.Equal(new[] { (71L, Platforms.Oculus), (72L, Platforms.Steam) }, await db.CachedLogins.OrderBy(login => login.accountId).ThenBy(login => login.platform) .Select(login => new ValueTuple(login.accountId, login.platform)).ToArrayAsync()); } [Fact] public async Task InfluencerRemovalUsesTheCapturedEconomyRouteAndRequestShape() { var method = typeof(InfluencerPartnerProgramAPI).GetMethod(nameof(InfluencerPartnerProgramAPI.RemoveInfluencer))!; Assert.Contains(method.GetCustomAttributes(), attribute => attribute.Template == "remove"); // No action parameter on purpose: the economy client posts this required body // urlencoded, and binding it with [FromBody] answered 415 Unsupported Media Type. Assert.Empty(method.GetParameters()); var accountId = typeof(InfluencerRemoveRequest).GetProperty("influencerAccountId"); Assert.NotNull(accountId); Assert.Equal(typeof(int), accountId!.PropertyType); var body = System.Text.Encoding.UTF8.GetBytes("influencerAccountId=7"); var api = new InfluencerPartnerProgramAPI { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { Request = { ContentType = "application/x-www-form-urlencoded", Body = new MemoryStream(body) } } } }; api.HttpContext.Request.ContentLength = body.Length; var result = Assert.IsType(await api.RemoveInfluencer()); var response = JsonSerializer.SerializeToElement(result.Value); Assert.True(response.GetProperty("success").GetBoolean()); } /// /// Both influencer reads answer a BARE NUMBER, because that is what the client's own signatures /// return: `HAHEDHADMKF(int) -> KDCMFGDLEKL<int?>` for `influencer?accountId=` and /// `BJLFMAMBEEJ() -> KDCMFGDLEKL<int?>` for `myinfluencer`. The captured real answer to the /// first is a bare number (`28848275`, "Influencer Support" in /// `research/RecNet API.postman_collection.json`); sending `{AccountId, Code}` there made every /// profile open with `Exception: Malformed Response: '{ "AccountId": 2, "Code": "COACH" }'`, /// and `myinfluencer` then collected BOTH of its own rejections - `'{}'` for the empty object /// and `'{ "influencerAccountId": 0 }'` for the one-member object it was replaced with, which /// is still in the log on every boot. 0 means "nobody". /// [Fact] public void BothInfluencerReadsServeABareNumber() { var previousInfluencers = DBConfig.TestInfluencerAccountIds; DBConfig.TestInfluencerAccountIds = new List { 2 }; try { var api = new InfluencerPartnerProgramAPI { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; Assert.Equal(2L, Assert.IsType(api.GetInfluencer(2)).Value); Assert.IsType(api.GetInfluencer(999)); api.ControllerContext.HttpContext.User = new ClaimsPrincipal( new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "2") }, "TestAuth")); var mine = Assert.IsType(api.GetMyInfluencer()); var mineJson = JsonSerializer.SerializeToElement(mine.Value); Assert.Equal(JsonValueKind.Number, mineJson.ValueKind); Assert.Equal(2L, mineJson.GetInt64()); // A caller who supports nobody gets the number 0, not an object carrying it. api.ControllerContext.HttpContext.User = new ClaimsPrincipal( new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "7") }, "TestAuth")); var none = Assert.IsType(api.GetMyInfluencer()); var noneJson = JsonSerializer.SerializeToElement(none.Value); Assert.Equal(JsonValueKind.Number, noneJson.ValueKind); Assert.Equal(0L, noneJson.GetInt64()); } finally { DBConfig.TestInfluencerAccountIds = previousInfluencers; } } }