diff --git a/SVSim.EmulatedEntrypoint/Controllers/ImmutableDataController.cs b/SVSim.EmulatedEntrypoint/Controllers/ImmutableDataController.cs new file mode 100644 index 00000000..7fd34477 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/ImmutableDataController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ImmutableData; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ImmutableData; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// Family /immutable_data/*. v1 hosts the single endpoint card_master; +/// future siblings (e.g. mission masters, asset bundle hash tables) would land here. +/// +[Route("immutable_data")] +public class ImmutableDataController : SVSimController +{ + private readonly ICardMasterPayloadProvider _provider; + + public ImmutableDataController(ICardMasterPayloadProvider provider) + { + _provider = provider; + } + + /// + /// Returns the base64+gzip+json+csv card-master payload. Tier 1 serves a static prod + /// snapshot regardless of the request's card_master_hash; freshness gating is + /// handled on /load/index instead. + /// + [HttpPost("card_master")] + public ActionResult CardMaster([FromBody] CardMasterRequest req) + { + if (!_provider.IsAvailable) + { + return StatusCode(503, "card-master payload not available"); + } + return Ok(new CardMasterResponse { CardMaster = _provider.Base64Blob }); + } +} diff --git a/SVSim.UnitTests/Controllers/ImmutableDataControllerTests.cs b/SVSim.UnitTests/Controllers/ImmutableDataControllerTests.cs new file mode 100644 index 00000000..f5a212bf --- /dev/null +++ b/SVSim.UnitTests/Controllers/ImmutableDataControllerTests.cs @@ -0,0 +1,65 @@ +using System.IO.Compression; +using System.Net; +using System.Text; +using System.Text.Json; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class ImmutableDataControllerTests +{ + private const string CardMasterRequestJson = + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","card_master_hash":""}"""; + + [Test] + public async Task CardMaster_returns_200_with_base64_payload() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/immutable_data/card_master", + new StringContent(CardMasterRequestJson, Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.TryGetProperty("card_master", out var blob), Is.True, + "Response missing card_master field. Body: " + body); + Assert.That(blob.ValueKind, Is.EqualTo(JsonValueKind.String)); + Assert.That(blob.GetString()!.Length, Is.GreaterThan(1_000_000), + "Expected ~1.27 MB base64 payload; got " + blob.GetString()!.Length); + } + + [Test] + public async Task CardMaster_payload_decodes_to_slot1_csv_with_expected_row_count() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/immutable_data/card_master", + new StringContent(CardMasterRequestJson, Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var b64 = doc.RootElement.GetProperty("card_master").GetString()!; + + var gzipped = Convert.FromBase64String(b64); + using var ms = new MemoryStream(gzipped); + using var gz = new GZipStream(ms, CompressionMode.Decompress); + using var sr = new StreamReader(gz, Encoding.UTF8); + var json = sr.ReadToEnd(); + + using var inner = JsonDocument.Parse(json); + Assert.That(inner.RootElement.TryGetProperty("1", out var slot1), Is.True, + "Decoded blob missing slot '1' (Default CardMasterId)"); + var csv = slot1.GetString()!; + var rowCount = csv.Split('\n').Length; + Assert.That(rowCount, Is.EqualTo(11867), + "Expected 11867 CSV rows from the 2026-06-03 captured blob; got " + rowCount); + Assert.That(csv.Split('\n')[0], Does.StartWith("930844060,930844061,90000,CN_930844060,0,930844060,"), + "Row 0 doesn't match the captured prod row — blob may be corrupted or swapped."); + } +}