diff --git a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs index ef0b64a..5d4269e 100644 --- a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs +++ b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs @@ -1,10 +1,13 @@ using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using MessagePack; +using MessagePack.Resolvers; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Primitives; -using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using SVSim.Database.Models; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Extensions; @@ -23,6 +26,16 @@ public class ShadowverseTranslationMiddleware : IMiddleware private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; private readonly ShadowverseSessionService _sessionService; + // Serialization policy MUST match what AddJsonOptions configured on the controllers, or the + // model binder won't find the snake_case keys we write into the synthetic request body and + // every request 400s with empty ModelState. WhenWritingNull is irrelevant for request + // serialization but kept here for symmetry. + private static readonly JsonSerializerOptions ControllerJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + public ShadowverseTranslationMiddleware(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, ShadowverseSessionService sessionService) { _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; @@ -58,9 +71,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware // Decrypt incoming data. requestBytes = Encryption.Decrypt(requestBytes, udid); - object? data = MessagePackSerializer.Deserialize(endpointDescriptor.Parameters.FirstOrDefault().ParameterType, - requestBytes); - string json = JsonConvert.SerializeObject(data); + Type requestType = endpointDescriptor.Parameters.FirstOrDefault().ParameterType; + object? data = MessagePackSerializer.Deserialize(requestType, requestBytes); + // Re-serialize via System.Text.Json with the SAME options the controllers use, so the + // model binder sees snake_case keys it can match. Using JsonConvert here writes the + // CLR property names (PascalCase) and every property silently binds to default → 400. + string json = JsonSerializer.Serialize(data, requestType, ControllerJsonOptions); StringContent newStream = new StringContent(json, Encoding.UTF8, "application/json"); context.Request.Body = newStream.ReadAsStream(); context.Request.Headers.ContentType = new StringValues("application/json"); @@ -68,19 +84,20 @@ public class ShadowverseTranslationMiddleware : IMiddleware await next.Invoke(context); Viewer? viewer = context.GetViewer(); - - // Grab the response object - Type responseType = ((ControllerActionDescriptor)endpointDescriptor).MethodInfo.ReturnType; - if (responseType.IsGenericType && responseType.GetGenericTypeDefinition() == typeof(Task<>)) - { - responseType = responseType.GetGenericArguments()[0]; - } + + // Read the controller's JSON response body. System.Text.Json was configured with + // SnakeCaseLower + WhenWritingNull, so the JSON keys are already in the wire shape and + // null/optional properties have been omitted. Parse to a JToken tree to preserve that + // "absent vs null" information — going back through a typed DTO via JsonConvert would + // re-introduce nulls for missing properties and they'd reach the client as msgpack Nil. using MemoryStream responseBytesStream = new MemoryStream(); context.Response.Body.Seek(0, SeekOrigin.Begin); await context.Response.Body.CopyToAsync(responseBytesStream); - byte[] responseBytes = responseBytesStream.ToArray(); - object? responseData = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(responseBytes), responseType); - + string responseJson = Encoding.UTF8.GetString(responseBytesStream.ToArray()); + object? responseData = string.IsNullOrEmpty(responseJson) + ? null + : ConvertJsonTreeToPlainObject(JToken.Parse(responseJson)); + // Wrap the response in a datawrapper DataWrapper wrappedResponseData = new DataWrapper { @@ -88,8 +105,14 @@ public class ShadowverseTranslationMiddleware : IMiddleware DataHeaders = new DataHeaders { Servertime = DateTime.UtcNow.Ticks, - Sid = - context.Request.Headers[NetworkConstants.SessionIdHeaderName].FirstOrDefault(), + // SID intentionally empty. See docs/api-spec/common/envelope.md §"SID + // rotation" — the client's SessionId is a hash-on-read property, so echoing + // the request's SID poisons its backing field and the next request hashes + // the hash, missing our SID→UDID dict and crashing decryption. To rotate + // sessions in the future, use the "stable-prefix + counter" pattern from + // that doc (Option B), and pre-hash the rotated value to index the map by + // what the client will actually send back on the next request. + Sid = "", // TODO error handling ResultCode = 1, ShortUdid = viewer.ShortUdid, @@ -97,10 +120,39 @@ public class ShadowverseTranslationMiddleware : IMiddleware } }; - // Convert the response into a messagepack, encrypt it - byte[] packedData = MessagePackSerializer.Serialize(wrappedResponseData); + // Convert the response into a messagepack, encrypt it. ContractlessStandardResolver + // walks the DataWrapper's typed properties (DataHeaders) AND the boxed object/list/ + // primitive tree under Data — emitting only the keys present in the dictionary. + var msgPackOptions = MessagePackSerializerOptions.Standard + .WithResolver(ContractlessStandardResolver.Instance); + byte[] packedData = MessagePackSerializer.Serialize(wrappedResponseData, msgPackOptions); packedData = Encryption.Encrypt(packedData, udid); await originalResponsebody.WriteAsync(Encoding.UTF8.GetBytes(Convert.ToBase64String(packedData))); context.Response.Body = originalResponsebody; } + + /// + /// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless + /// resolver understands: objects → Dictionary<string, object?>, arrays → + /// List<object?>, scalars unboxed to their nearest primitive. Crucially, JSON + /// objects that lacked a key DON'T get one in the dictionary — preserving "absent" as a + /// distinct state from "null" all the way to the msgpack writer. + /// + internal static object? ConvertJsonTreeToPlainObject(JToken? token) + { + if (token is null || token.Type == JTokenType.Null) return null; + return token.Type switch + { + JTokenType.Object => token.Children() + .ToDictionary(p => p.Name, p => ConvertJsonTreeToPlainObject(p.Value)), + JTokenType.Array => token.Children().Select(ConvertJsonTreeToPlainObject).ToList(), + JTokenType.Integer => token.Value(), + JTokenType.Float => token.Value(), + JTokenType.String => token.Value(), + JTokenType.Boolean => token.Value(), + JTokenType.Date => token.Value(), + JTokenType.Bytes => token.Value(), + _ => token.ToString() + }; + } } \ No newline at end of file diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/GameStartResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/GameStartResponse.cs index a568078..d2b3282 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/GameStartResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/GameStartResponse.cs @@ -48,13 +48,20 @@ public class GameStartResponse [Key("transition_account_data")] public List TransitionAccountData { get; set; } = new(); - // INTENTIONALLY OMITTED: `rewrite_viewer_id` and `account_delete_reservation_status`. - // Both are presence-checked by the client via `Keys.Contains(...)` + `.ToInt()` with no - // null guard. MessagePack-CSharp writes [Key] properties unconditionally (null → Nil), - // and the System.Text.Json `WhenWritingNull` ignore only affects the plain-JSON path. - // So including these as nullable properties is a guaranteed NRE on the encrypted client - // path. We don't need them — the client tolerates their absence — so don't declare them. - // Re-add only if we have a real value to send. + /// + /// When present, client overwrites Certification.ViewerId with this value. Optional + /// — leave null to omit. The serialization pipeline (JSON + msgpack via the translation + /// middleware) drops null properties end-to-end, so the client sees the key as absent. + /// + [Key("rewrite_viewer_id")] + public long? RewriteViewerId { get; set; } + + /// + /// Presence indicates the user has applied for account deletion (value ignored by client at + /// this stage). Optional — leave null to omit. + /// + [Key("account_delete_reservation_status")] + public int? AccountDeleteReservationStatus { get; set; } // --- Agreement / consent state (all required) --- diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 9a5eab5..4f2a365 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using SVSim.Database; @@ -22,10 +23,16 @@ public class Program builder.Services.AddControllers().AddJsonOptions(opt => { + // Wire-format congruence: the encrypted msgpack path uses snake_case [Key("...")] + // names; the plain-JSON path runs through System.Text.Json. Match them by using + // SnakeCaseLower naming policy here so both paths emit identical key names — and + // so the translation middleware can hand JSON keys straight through to msgpack + // without per-property name remapping. + opt.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; // Production omits null/optional fields entirely; the client uses // `Keys.Contains(name)` as a presence check and calls `.ToInt()` (etc.) on the // value without a null guard. Emitting `"key":null` makes Contains return true and - // crashes the client. Match prod by dropping nulls during serialization. + // crashes the client. Drop nulls during serialization so missing == absent. opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; }); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/SVSim.EmulatedEntrypoint/SVSim.EmulatedEntrypoint.csproj b/SVSim.EmulatedEntrypoint/SVSim.EmulatedEntrypoint.csproj index 869ba35..f607413 100644 --- a/SVSim.EmulatedEntrypoint/SVSim.EmulatedEntrypoint.csproj +++ b/SVSim.EmulatedEntrypoint/SVSim.EmulatedEntrypoint.csproj @@ -28,6 +28,10 @@ + + + + diff --git a/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs b/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs index 2c3b800..83165e4 100644 --- a/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs +++ b/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs @@ -1,9 +1,9 @@ using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; +using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; -using Newtonsoft.Json; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Viewer; @@ -16,6 +16,14 @@ namespace SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; public class SteamSessionAuthenticationHandler : AuthenticationHandler { + // Must mirror the controller-side JSON options — the translation middleware rewrites the + // request body in snake_case, and we have to read it back the same way or every property + // binds to null and we NRE downstream against the Steam ticket. + private static readonly JsonSerializerOptions RequestJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + private readonly SteamSessionService _sessionService; private readonly IViewerRepository _viewerRepository; public SteamSessionAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, SteamSessionService sessionService, IViewerRepository viewerRepository) : base(options, logger, encoder) @@ -48,13 +56,21 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler(requestString); - - if (requestJson is null) + BaseRequest? requestJson; + try + { + requestJson = JsonSerializer.Deserialize(requestString, RequestJsonOptions); + } + catch (JsonException) { return AuthenticateResult.Fail("Invalid request body."); } - + + if (requestJson is null || string.IsNullOrEmpty(requestJson.SteamSessionTicket)) + { + return AuthenticateResult.Fail("Invalid request body."); + } + // Check steam session validity bool sessionIsValid = _sessionService.IsTicketValidForUser(requestJson.SteamSessionTicket, requestJson.SteamId); if (!sessionIsValid) diff --git a/SVSim.EmulatedEntrypoint/Services/SteamSessionService.cs b/SVSim.EmulatedEntrypoint/Services/SteamSessionService.cs index a97f176..f0dc884 100644 --- a/SVSim.EmulatedEntrypoint/Services/SteamSessionService.cs +++ b/SVSim.EmulatedEntrypoint/Services/SteamSessionService.cs @@ -19,6 +19,14 @@ public class SteamSessionService : IDisposable /// whether the ticket is valid for the given steamid public bool IsTicketValidForUser(string ticket, ulong steamId) { + if (string.IsNullOrEmpty(ticket)) + { + // Caller already shouldn't pass null/empty here, but a misshaped request body + // (e.g. wrong casing) used to NRE on the ConcurrentDictionary lookup below. + // Fail cleanly so the auth pipeline returns 401 instead of crashing the request. + return false; + } + if (_validatedSessionTickets.TryGetValue(ticket, out ulong storedSteamId)) { return storedSteamId == steamId; diff --git a/SVSim.UnitTests/Controllers/CheckControllerTests.cs b/SVSim.UnitTests/Controllers/CheckControllerTests.cs index 71ee1fe..ed83fda 100644 --- a/SVSim.UnitTests/Controllers/CheckControllerTests.cs +++ b/SVSim.UnitTests/Controllers/CheckControllerTests.cs @@ -13,10 +13,10 @@ namespace SVSim.UnitTests.Controllers; public class CheckControllerTests { private const string BaseRequestJson = - """{"viewerId":"0","steamId":0,"steamSessionTicket":""}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; private const string GameStartRequestJson = - """{"viewerId":"0","steamId":0,"steamSessionTicket":"","appType":0,"campaignData":"","campaignSign":"","campaignUser":0}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","app_type":0,"campaign_data":"","campaign_sign":"","campaign_user":0}"""; [Test] public async Task SpecialTitle_returns_default_title_id() @@ -31,7 +31,7 @@ public class CheckControllerTests var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); - Assert.That(doc.RootElement.GetProperty("titleImageId").GetString(), Is.EqualTo("0")); + Assert.That(doc.RootElement.GetProperty("title_image_id").GetString(), Is.EqualTo("0")); } [Test] @@ -51,30 +51,30 @@ public class CheckControllerTests var root = doc.RootElement; // now_tutorial_step is a STRING on the wire (prod sends "100"); client calls .ToInt(). - Assert.That(root.GetProperty("nowTutorialStep").GetString(), Is.EqualTo("100"), + Assert.That(root.GetProperty("now_tutorial_step").GetString(), Is.EqualTo("100"), "RegisterViewer's seed-config default sets tutorial_state=100 (tutorial complete)."); - Assert.That(root.GetProperty("tosState").GetInt32(), Is.EqualTo(1)); - Assert.That(root.GetProperty("policyState").GetInt32(), Is.EqualTo(1)); - Assert.That(root.GetProperty("korAuthorityState").GetInt32(), Is.EqualTo(0)); - Assert.That(root.GetProperty("tosId").GetInt32(), Is.EqualTo(1)); - Assert.That(root.GetProperty("policyId").GetInt32(), Is.EqualTo(1)); - Assert.That(root.GetProperty("korAuthorityId").GetInt32(), Is.EqualTo(0)); + Assert.That(root.GetProperty("tos_state").GetInt32(), Is.EqualTo(1)); + Assert.That(root.GetProperty("policy_state").GetInt32(), Is.EqualTo(1)); + Assert.That(root.GetProperty("kor_authority_state").GetInt32(), Is.EqualTo(0)); + Assert.That(root.GetProperty("tos_id").GetInt32(), Is.EqualTo(1)); + Assert.That(root.GetProperty("policy_id").GetInt32(), Is.EqualTo(1)); + Assert.That(root.GetProperty("kor_authority_id").GetInt32(), Is.EqualTo(0)); // Prod-shape fields (not strictly read by GameStartCheckTask.Parse but sent by prod). - Assert.That(root.GetProperty("nowViewerId").GetInt64(), Is.GreaterThan(0)); - Assert.That(root.GetProperty("nowName").GetString(), Is.Not.Empty); - Assert.That(root.GetProperty("nowRank").ValueKind, Is.EqualTo(JsonValueKind.Object)); + Assert.That(root.GetProperty("now_viewer_id").GetInt64(), Is.GreaterThan(0)); + Assert.That(root.GetProperty("now_name").GetString(), Is.Not.Empty); + Assert.That(root.GetProperty("now_rank").ValueKind, Is.EqualTo(JsonValueKind.Object)); // Steam connection should round-trip into transition_account_data — all three fields // serialized as strings (matches prod wire shape). - var transitions = root.GetProperty("transitionAccountData"); + var transitions = root.GetProperty("transition_account_data"); Assert.That(transitions.ValueKind, Is.EqualTo(JsonValueKind.Array)); Assert.That(transitions.GetArrayLength(), Is.EqualTo(1), "Seeded viewer has exactly one Steam social account connection."); - Assert.That(transitions[0].GetProperty("socialAccountType").GetString(), + Assert.That(transitions[0].GetProperty("social_account_type").GetString(), Is.EqualTo(((int)SVSim.Database.Enums.SocialAccountType.Steam).ToString())); - Assert.That(transitions[0].GetProperty("socialAccountId").GetString(), Is.Not.Empty); - Assert.That(transitions[0].GetProperty("connectedViewerId").GetString(), Is.Not.Empty); + Assert.That(transitions[0].GetProperty("social_account_id").GetString(), Is.Not.Empty); + Assert.That(transitions[0].GetProperty("connected_viewer_id").GetString(), Is.Not.Empty); } [Test] @@ -97,9 +97,9 @@ public class CheckControllerTests using var doc = JsonDocument.Parse(body); var root = doc.RootElement; - Assert.That(root.TryGetProperty("rewriteViewerId", out _), Is.False, + Assert.That(root.TryGetProperty("rewrite_viewer_id", out _), Is.False, "rewrite_viewer_id must NOT be present in the response — client NREs on null .ToInt()."); - Assert.That(root.TryGetProperty("accountDeleteReservationStatus", out _), Is.False, + Assert.That(root.TryGetProperty("account_delete_reservation_status", out _), Is.False, "account_delete_reservation_status must NOT be present — presence triggers client behavior."); } diff --git a/SVSim.UnitTests/Controllers/DeckControllerTests.cs b/SVSim.UnitTests/Controllers/DeckControllerTests.cs index 55c3670..2b9ebd5 100644 --- a/SVSim.UnitTests/Controllers/DeckControllerTests.cs +++ b/SVSim.UnitTests/Controllers/DeckControllerTests.cs @@ -17,7 +17,7 @@ namespace SVSim.UnitTests.Controllers; public class DeckControllerTests { private static string DeckFormatRequestJson(Format f) => - $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckFormat":{{(int)f}}}"""; + $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{(int)f}}}"""; private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); @@ -49,7 +49,7 @@ public class DeckControllerTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); - var decks = doc.RootElement.GetProperty("userDeckList"); + var decks = doc.RootElement.GetProperty("user_deck_list"); Assert.That(decks.GetArrayLength(), Is.EqualTo(2), "Only Rotation-format decks should be returned for a Rotation request."); var names = Enumerable.Range(0, decks.GetArrayLength()) @@ -72,7 +72,7 @@ public class DeckControllerTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); - var decks = doc.RootElement.GetProperty("userDeckList"); + var decks = doc.RootElement.GetProperty("user_deck_list"); Assert.That(decks.GetArrayLength(), Is.EqualTo(1)); Assert.That(decks[0].GetProperty("name").GetString(), Is.EqualTo("Unlimited Deck")); } @@ -90,7 +90,7 @@ public class DeckControllerTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); - var decks = doc.RootElement.GetProperty("userDeckList"); + var decks = doc.RootElement.GetProperty("user_deck_list"); Assert.That(decks.GetArrayLength(), Is.EqualTo(0)); } @@ -108,7 +108,7 @@ public class DeckControllerTests var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); - Assert.That(doc.RootElement.GetProperty("emptyDeckNum").GetInt32(), Is.EqualTo(1)); + Assert.That(doc.RootElement.GetProperty("empty_deck_num").GetInt32(), Is.EqualTo(1)); } [Test] @@ -127,7 +127,7 @@ public class DeckControllerTests var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); - Assert.That(doc.RootElement.GetProperty("emptyDeckNum").GetInt32(), Is.EqualTo(3), + Assert.That(doc.RootElement.GetProperty("empty_deck_num").GetInt32(), Is.EqualTo(3), "Algorithm must return the smallest free slot, not just one past the highest used."); } @@ -142,10 +142,10 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var updateJson = $$""" - {"viewerId":"0","steamId":0,"steamSessionTicket":"", - "deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}}, - "isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Fresh Deck", - "isDelete":0,"deckFormat":0} + {"viewer_id":"0","steam_id":0,"steam_session_ticket":"", + "deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}}, + "is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Fresh Deck", + "is_delete":0,"deck_format":0} """; var response = await client.PostAsync("/deck/update", JsonBody(updateJson)); @@ -170,10 +170,10 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var updateJson = $$""" - {"viewerId":"0","steamId":0,"steamSessionTicket":"", - "deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}}, - "isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Renamed", - "isDelete":0,"deckFormat":0} + {"viewer_id":"0","steam_id":0,"steam_session_ticket":"", + "deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}}, + "is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Renamed", + "is_delete":0,"deck_format":0} """; await client.PostAsync("/deck/update", JsonBody(updateJson)); @@ -194,10 +194,10 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var deleteJson = $$""" - {"viewerId":"0","steamId":0,"steamSessionTicket":"", - "deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}}, - "isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":null, - "isDelete":1,"deckFormat":0} + {"viewer_id":"0","steam_id":0,"steam_session_ticket":"", + "deck_no":1,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}}, + "is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":null, + "is_delete":1,"deck_format":0} """; var response = await client.PostAsync("/deck/update", JsonBody(deleteJson)); @@ -219,16 +219,16 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var updateJson = $$""" - {"viewerId":"0","steamId":0,"steamSessionTicket":"", - "deckNo":2,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}}, - "isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Second", - "isDelete":0,"deckFormat":0} + {"viewer_id":"0","steam_id":0,"steam_session_ticket":"", + "deck_no":2,"class_id":{{classId}},"leader_skin_id":{{leaderSkinId}}, + "is_random_leader_skin":false,"sleeve_id":{{sleeveId}},"deck_name":"Second", + "is_delete":0,"deck_format":0} """; var response = await client.PostAsync("/deck/update", JsonBody(updateJson)); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); - var decks = doc.RootElement.GetProperty("userDeckList"); + var decks = doc.RootElement.GetProperty("user_deck_list"); Assert.That(decks.GetArrayLength(), Is.EqualTo(2), "/deck/update should hand back the full refreshed list, saving the client a follow-up."); var names = Enumerable.Range(0, decks.GetArrayLength()) @@ -247,12 +247,12 @@ public class DeckControllerTests await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Old Name"); using var client = factory.CreateAuthenticatedClient(viewerId); - var json = """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckName":"New Name","deckFormat":0}"""; + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_name":"New Name","deck_format":0}"""; var response = await client.PostAsync("/deck/update_name", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); - Assert.That(doc.RootElement.GetProperty("userDeck").GetProperty("name").GetString(), + Assert.That(doc.RootElement.GetProperty("user_deck").GetProperty("name").GetString(), Is.EqualTo("New Name")); using var scope = factory.Services.CreateScope(); @@ -276,12 +276,12 @@ public class DeckControllerTests } using var client = factory.CreateAuthenticatedClient(viewerId); - var json = $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"sleeveId":{{sleeveId}},"deckFormat":0}"""; + var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deckNo":1,"sleeve_id":{{sleeveId}},"deckFormat":0}"""; var response = await client.PostAsync("/deck/update_sleeve", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); - Assert.That(doc.RootElement.GetProperty("userDeck").GetProperty("sleeveId").GetInt32(), + Assert.That(doc.RootElement.GetProperty("user_deck").GetProperty("sleeve_id").GetInt32(), Is.EqualTo(sleeveId)); } @@ -299,14 +299,14 @@ public class DeckControllerTests } using var client = factory.CreateAuthenticatedClient(viewerId); - var json = $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"leaderSkinId":{{skinId}},"deckFormat":0}"""; + var json = $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"leader_skin_id":{{skinId}},"deck_format":0}"""; var response = await client.PostAsync("/deck/update_leader_skin", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); - var userDeck = doc.RootElement.GetProperty("userDeck"); - Assert.That(userDeck.GetProperty("leaderSkinId").GetInt32(), Is.EqualTo(skinId)); - Assert.That(userDeck.GetProperty("isRandomLeaderSkin").GetInt32(), Is.EqualTo(0), + var userDeck = doc.RootElement.GetProperty("user_deck"); + Assert.That(userDeck.GetProperty("leader_skin_id").GetInt32(), Is.EqualTo(skinId)); + Assert.That(userDeck.GetProperty("is_random_leader_skin").GetInt32(), Is.EqualTo(0), "Selecting a specific leader skin clears the random-skin flag."); } @@ -325,17 +325,17 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var json = - $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckFormat":0,"leaderSkinIdList":[{{string.Join(',', pool)}}]}"""; + $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":0,"leader_skin_id_list":[{{string.Join(',', pool)}}]}"""; var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json)); var body = await response.Content.ReadAsStringAsync(); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); - var userDeck = doc.RootElement.GetProperty("userDeck"); - Assert.That(pool, Contains.Item(userDeck.GetProperty("leaderSkinId").GetInt32()), + var userDeck = doc.RootElement.GetProperty("user_deck"); + Assert.That(pool, Contains.Item(userDeck.GetProperty("leader_skin_id").GetInt32()), "Chosen skin must come from the supplied pool."); - Assert.That(userDeck.GetProperty("isRandomLeaderSkin").GetInt32(), Is.EqualTo(1)); + Assert.That(userDeck.GetProperty("is_random_leader_skin").GetInt32(), Is.EqualTo(1)); } [Test] @@ -347,7 +347,7 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var json = - """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckFormat":0,"leaderSkinIdList":[]}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"deck_format":0,"leader_skin_id_list":[]}"""; var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); @@ -365,7 +365,7 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var json = - """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckOrder":[2,1],"deckFormat":0}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_order":[2,1],"deck_format":0}"""; var response = await client.PostAsync("/deck/update_order", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); @@ -382,7 +382,7 @@ public class DeckControllerTests using var client = factory.CreateAuthenticatedClient(viewerId); var json = - """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNoList":[1,3],"deckFormat":0}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no_list":[1,3],"deck_format":0}"""; var response = await client.PostAsync("/deck/delete_deck_list", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); @@ -401,7 +401,7 @@ public class DeckControllerTests long viewerId = await factory.SeedViewerAsync(); using var client = factory.CreateAuthenticatedClient(viewerId); - var json = """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"classId":1}"""; + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"class_id":1}"""; var response = await client.PostAsync("/deck/set_deck_redis", JsonBody(json)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); diff --git a/SVSim.UnitTests/Controllers/LoadControllerTests.cs b/SVSim.UnitTests/Controllers/LoadControllerTests.cs index 6700a46..ebce139 100644 --- a/SVSim.UnitTests/Controllers/LoadControllerTests.cs +++ b/SVSim.UnitTests/Controllers/LoadControllerTests.cs @@ -14,7 +14,7 @@ namespace SVSim.UnitTests.Controllers; public class LoadControllerTests { private const string IndexRequestJson = - """{"viewerId":"0","steamId":0,"steamSessionTicket":"","carrier":"steam","cardMasterHash":""}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"steam","card_master_hash":""}"""; /// /// JSON keys (camelCased C# property names) for fields the client reads unconditionally. @@ -25,17 +25,17 @@ public class LoadControllerTests /// private static readonly string[] RequiredIndexKeys = { - "userTutorial", "userInfo", "userCurrency", "userItems", - "userRotationDecks", "userUnlimitedDecks", "userMyRotationDecks", - "userCards", "userClasses", "sleeves", "userEmblems", - "userDegrees", "leaderSkins", "myPageBackgrounds", - "userRankInfo", "userRankedMatches", "dailyLoginBonus", "arenaConfig", - "redEtherOverrides", "maintenanceCards", "arenaInfos", "rankInfo", - "classExp", "loadingTipCardExclusions", "defaultSettings", - "unlimitedBanList", "rotationSets", - "reprintedCards", "spotCards", "featureMaintenances", - "specialCrystalInfos", "openBattlefieldIds", "lootBoxRegulations", - "gatheringInfo", "userConfig", "deckFormat", "cardSetIdForResourceDlView" + "user_tutorial", "user_info", "user_currency", "user_items", + "user_rotation_decks", "user_unlimited_decks", "user_my_rotation_decks", + "user_cards", "user_classes", "sleeves", "user_emblems", + "user_degrees", "leader_skins", "my_page_backgrounds", + "user_rank_info", "user_ranked_matches", "daily_login_bonus", "arena_config", + "red_ether_overrides", "maintenance_cards", "arena_infos", "rank_info", + "class_exp", "loading_tip_card_exclusions", "default_settings", + "unlimited_ban_list", "rotation_sets", + "reprinted_cards", "spot_cards", "feature_maintenances", + "special_crystal_infos", "open_battlefield_ids", "loot_box_regulations", + "gathering_info", "user_config", "deck_format", "card_set_id_for_resource_dl_view" }; private static async Task PostIndexAndReadBody(SVSimTestFactory factory, long viewerId) @@ -100,7 +100,7 @@ public class LoadControllerTests var root = await PostIndexAndReadBody(factory, viewerId); - Assert.That(root.GetProperty("userRankInfo").ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(root.GetProperty("user_rank_info").ValueKind, Is.EqualTo(JsonValueKind.Array)); } [Test] @@ -114,7 +114,7 @@ public class LoadControllerTests var root = await PostIndexAndReadBody(factory, viewerId); - Assert.That(root.GetProperty("userRankInfo").GetArrayLength(), Is.EqualTo(5)); + Assert.That(root.GetProperty("user_rank_info").GetArrayLength(), Is.EqualTo(5)); } [Test] @@ -127,7 +127,7 @@ public class LoadControllerTests var root = await PostIndexAndReadBody(factory, viewerId); - Assert.That(root.GetProperty("rotationSets").GetArrayLength(), + Assert.That(root.GetProperty("rotation_sets").GetArrayLength(), Is.GreaterThanOrEqualTo(2)); } @@ -141,12 +141,12 @@ public class LoadControllerTests var root = await PostIndexAndReadBody(factory, viewerId); - foreach (var key in new[] { "userRotationDecks", "userUnlimitedDecks", "userMyRotationDecks" }) + foreach (var key in new[] { "user_rotation_decks", "user_unlimited_decks", "user_my_rotation_decks" }) { var container = root.GetProperty(key); Assert.That(container.ValueKind, Is.EqualTo(JsonValueKind.Object), $"{key} should be the UserFormatDeckInfo object wrapper, not a raw array."); - var inner = container.GetProperty("userDecks"); + var inner = container.GetProperty("user_decks"); Assert.That(inner.ValueKind, Is.EqualTo(JsonValueKind.Array)); Assert.That(inner.GetArrayLength(), Is.EqualTo(0), $"{key}.userDecks must be an empty array for a deckless viewer, not null."); diff --git a/SVSim.UnitTests/Controllers/PracticeControllerTests.cs b/SVSim.UnitTests/Controllers/PracticeControllerTests.cs index 9de6a4d..61744ac 100644 --- a/SVSim.UnitTests/Controllers/PracticeControllerTests.cs +++ b/SVSim.UnitTests/Controllers/PracticeControllerTests.cs @@ -14,10 +14,10 @@ namespace SVSim.UnitTests.Controllers; public class PracticeControllerTests { private const string BaseRequestJson = - """{"viewerId":"0","steamId":0,"steamSessionTicket":""}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; private static string DeckFormatRequestJson(Format f) => - $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckFormat":{{(int)f}}}"""; + $$"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":{{(int)f}}}"""; [Test] public async Task Info_returns_non_empty_opponent_array() @@ -36,7 +36,7 @@ public class PracticeControllerTests Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Array), "/practice/info returns a bare array (no wrapper object) per spec."); Assert.That(doc.RootElement.GetArrayLength(), Is.GreaterThan(0)); - Assert.That(doc.RootElement[0].GetProperty("practiceId").GetInt32(), Is.GreaterThan(0)); + Assert.That(doc.RootElement[0].GetProperty("practice_id").GetInt32(), Is.GreaterThan(0)); } [Test] @@ -55,8 +55,8 @@ public class PracticeControllerTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); - var rotation = doc.RootElement.GetProperty("userDeckRotation"); - var unlimited = doc.RootElement.GetProperty("userDeckUnlimited"); + var rotation = doc.RootElement.GetProperty("user_deck_rotation"); + var unlimited = doc.RootElement.GetProperty("user_deck_unlimited"); Assert.That(rotation.GetArrayLength(), Is.EqualTo(1)); Assert.That(rotation[0].GetProperty("name").GetString(), Is.EqualTo("Rotation Deck")); Assert.That(unlimited.GetArrayLength(), Is.EqualTo(1)); @@ -77,8 +77,8 @@ public class PracticeControllerTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); - Assert.That(doc.RootElement.GetProperty("userDeckRotation").GetArrayLength(), Is.EqualTo(0)); - Assert.That(doc.RootElement.GetProperty("userDeckUnlimited").GetArrayLength(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("user_deck_rotation").GetArrayLength(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("user_deck_unlimited").GetArrayLength(), Is.EqualTo(0)); } [Test] @@ -104,7 +104,7 @@ public class PracticeControllerTests // recoveryData is an opaque JSON blob serialized to string by the client; the server // is supposed to accept it without validation. Anything goes. var finishJson = - """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"isWin":1,"evolveCount":2,"totalTurn":5,"enemyClassId":3,"difficulty":1,"deckFormat":0,"classId":1,"recoveryData":"{\"opaque\":\"blob\"}"}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"is_win":1,"evolve_count":2,"total_turn":5,"enemy_class_id":3,"difficulty":1,"deck_format":0,"class_id":1,"recovery_data":"{\"opaque\":\"blob\"}"}"""; var response = await client.PostAsync("/practice/finish", new StringContent(finishJson, Encoding.UTF8, "application/json")); @@ -113,8 +113,8 @@ public class PracticeControllerTests Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var doc = JsonDocument.Parse(body); - Assert.That(doc.RootElement.GetProperty("getClassExperience").GetInt32(), Is.EqualTo(0)); - Assert.That(doc.RootElement.GetProperty("classExperience").GetInt32(), Is.EqualTo(0)); - Assert.That(doc.RootElement.GetProperty("rewardList").GetArrayLength(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0)); } } diff --git a/SVSim.UnitTests/Infrastructure/WireSerializationTests.cs b/SVSim.UnitTests/Infrastructure/WireSerializationTests.cs new file mode 100644 index 0000000..892dea3 --- /dev/null +++ b/SVSim.UnitTests/Infrastructure/WireSerializationTests.cs @@ -0,0 +1,138 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using MessagePack; +using MessagePack.Resolvers; +using Newtonsoft.Json.Linq; +using SVSim.EmulatedEntrypoint.Middlewares; +using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses; + +namespace SVSim.UnitTests.Infrastructure; + +/// +/// Exercises the JSON → JTree → msgpack pipeline that runs inside +/// . The middleware itself is hard to unit-test +/// in isolation because it depends on HttpContext + the encryption layer; instead we re-run +/// the exact same transformation steps the middleware would and assert on the resulting +/// msgpack bytes converted back to JSON. +/// +/// What this guards: that a controller returning an object with mixed null/non-null optional +/// fields produces a msgpack map containing ONLY the non-null fields, with snake_case keys. +/// That's the load-bearing property — the Unity client's Keys.Contains + .ToInt() +/// idiom NREs the moment the wire serializes a present-but-null field. +/// +public class WireSerializationTests +{ + private static readonly JsonSerializerOptions ControllerJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private static readonly MessagePackSerializerOptions MsgPackOptions = + MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance); + + /// + /// Reproduces what the middleware does to a controller's response object: serialize via + /// our JSON options (snake_case, omit null) → parse JSON tree → convert to plain CLR tree + /// → msgpack-serialize → msgpack-to-JSON. Returns the JSON the client would see. + /// + private static JsonDocument RoundTripThroughWirePipeline(T response) + { + var json = JsonSerializer.Serialize(response, ControllerJsonOptions); + var tree = ShadowverseTranslationMiddleware.ConvertJsonTreeToPlainObject(JToken.Parse(json)); + var msgPackBytes = MessagePackSerializer.Serialize(tree, MsgPackOptions); + var msgPackJson = MessagePackSerializer.ConvertToJson(msgPackBytes, MsgPackOptions); + return JsonDocument.Parse(msgPackJson); + } + + [Test] + public void GameStartResponse_with_null_optional_fields_omits_them_from_msgpack() + { + var response = new GameStartResponse + { + NowViewerId = 42, + NowName = "Tester", + NowTutorialStep = "100", + IsSetTransitionPassword = true, + NowRank = new Dictionary { { "1", "RankName_010" } }, + TransitionAccountData = new List + { + new() { SocialAccountId = "76561198000000001", SocialAccountType = "5", ConnectedViewerId = "42" } + }, + // Both deliberately left at null — must NOT appear in the msgpack output. + RewriteViewerId = null, + AccountDeleteReservationStatus = null, + TosState = 1, PolicyState = 1, KorAuthorityState = 0, + TosId = 1, PolicyId = 1, KorAuthorityId = 0 + }; + + using var doc = RoundTripThroughWirePipeline(response); + var root = doc.RootElement; + + Assert.That(root.TryGetProperty("rewrite_viewer_id", out _), Is.False, + "Null `rewrite_viewer_id` must not appear in msgpack output — client NREs on Keys.Contains+.ToInt() against Nil."); + Assert.That(root.TryGetProperty("account_delete_reservation_status", out _), Is.False, + "Null `account_delete_reservation_status` must not appear (presence triggers client behavior)."); + + // Sanity: required fields still there, with snake_case keys. + Assert.That(root.GetProperty("now_viewer_id").GetInt64(), Is.EqualTo(42)); + Assert.That(root.GetProperty("now_name").GetString(), Is.EqualTo("Tester")); + Assert.That(root.GetProperty("now_tutorial_step").GetString(), Is.EqualTo("100")); + Assert.That(root.GetProperty("tos_state").GetInt64(), Is.EqualTo(1)); + } + + [Test] + public void GameStartResponse_with_set_optional_field_emits_it_in_msgpack() + { + // Mirror image of the omission test: when an optional field IS set, it must reach the + // client. Otherwise we'd have papered over the bug by accidentally dropping everything. + var response = new GameStartResponse + { + NowViewerId = 42, + NowName = "Tester", + NowTutorialStep = "100", + IsSetTransitionPassword = false, + RewriteViewerId = 999, // explicitly set + AccountDeleteReservationStatus = 1, + TosState = 1, PolicyState = 1, KorAuthorityState = 0, + TosId = 1, PolicyId = 1, KorAuthorityId = 0 + }; + + using var doc = RoundTripThroughWirePipeline(response); + var root = doc.RootElement; + + Assert.That(root.GetProperty("rewrite_viewer_id").GetInt64(), Is.EqualTo(999)); + Assert.That(root.GetProperty("account_delete_reservation_status").GetInt64(), Is.EqualTo(1)); + } + + [Test] + public void GameStartResponse_nested_transition_account_data_round_trips_with_snake_case_keys() + { + // The nested list of TransitionAccountData entries needs to keep its per-entry shape + // (snake_case keys, three string fields) after going JSON → tree → msgpack → JSON. + var response = new GameStartResponse + { + NowViewerId = 42, + NowName = "Tester", + NowTutorialStep = "100", + TransitionAccountData = new List + { + new() { SocialAccountId = "111", SocialAccountType = "5", ConnectedViewerId = "42" } + }, + TosState = 1, PolicyState = 1, KorAuthorityState = 0, + TosId = 1, PolicyId = 1, KorAuthorityId = 0 + }; + + using var doc = RoundTripThroughWirePipeline(response); + var transitions = doc.RootElement.GetProperty("transition_account_data"); + Assert.That(transitions.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(transitions.GetArrayLength(), Is.EqualTo(1)); + + var entry = transitions[0]; + Assert.That(entry.GetProperty("social_account_id").GetString(), Is.EqualTo("111")); + Assert.That(entry.GetProperty("social_account_type").GetString(), Is.EqualTo("5")); + Assert.That(entry.GetProperty("connected_viewer_id").GetString(), Is.EqualTo("42")); + } +} diff --git a/SVSim.UnitTests/RoutingSmokeTests.cs b/SVSim.UnitTests/RoutingSmokeTests.cs index 8f87224..78b27c7 100644 --- a/SVSim.UnitTests/RoutingSmokeTests.cs +++ b/SVSim.UnitTests/RoutingSmokeTests.cs @@ -30,7 +30,7 @@ public class RoutingSmokeTests } private const string ValidBaseRequestJson = - """{"viewerId":"0","steamId":0,"steamSessionTicket":""}"""; + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; [Test] public async Task CheckSpecialTitle_resolves_to_CheckController() @@ -48,7 +48,7 @@ public class RoutingSmokeTests var body = await response.Content.ReadAsStringAsync(); // Plain-JSON path uses camelCase (System.Text.Json default); MessagePack [Key] only applies // to the Unity-UA encrypted path through ShadowverseTranslationMiddleware. - Assert.That(body, Does.Contain("\"titleImageId\":\"0\""), + Assert.That(body, Does.Contain("\"title_image_id\":\"0\""), "SpecialTitleCheck should return the built-in title id \"0\"."); }