using System.Net.Http.Json; using System.Text.Json; using NUnit.Framework; using SVSim.Database.Enums; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; [TestFixture] public class FreeBattleControllerTests { // BaseRequest fields (viewer_id / steam_id / steam_session_ticket) are required by the // request DTOs — the ApiController's auto-validation rejects bodies missing them. We // post placeholder values here; the TestAuthHandler injects the real viewer-id via the // X-Test-Viewer-Id header set by CreateAuthenticatedClient, so these body values are // ignored by auth. private static readonly object DoMatchingBody = new { deck_no = 1, need_init = 1, log = 0, viewer_id = "0", steam_id = 0, steam_session_ticket = "", }; private static object FinishBody(int battleResult, int classId = 3) => new { battle_result = battleResult, is_retire = 0, recovery_data = "{}", class_id = classId, total_turn = 5, viewer_id = "0", steam_id = 0, steam_session_ticket = "", }; private static readonly object EmptyAuthedBody = new { viewer_id = "0", steam_id = 0, steam_session_ticket = "", }; [Test] public async Task DoMatching_unlimited_first_poll_returns_3002_RETRY_with_empty_node_server_url() { await using var factory = new SVSimTestFactory(); var viewerId = await factory.SeedViewerAsync(); await factory.SeedGlobalsAsync(); await factory.SeedDeckAsync(viewerId, Format.Unlimited, 1); var client = factory.CreateAuthenticatedClient(viewerId); var resp = await client.PostAsJsonAsync("/unlimited_free_battle/do_matching", DoMatchingBody); Assert.That(resp.IsSuccessStatusCode, Is.True, $"Expected 2xx, got {resp.StatusCode}"); var raw = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(raw); var data = doc.RootElement; Assert.That(data.GetProperty("matching_state").GetInt32(), Is.EqualTo(3002)); Assert.That(data.GetProperty("node_server_url").GetString(), Is.EqualTo(""), "Empty string, not absent — Phase 2 fix pattern."); } [Test] public async Task DoMatching_unlimited_two_viewers_pair_PvP() { await using var factory = new SVSimTestFactory(); var v1 = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_011UL, displayName: "Alice"); var v2 = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_012UL, displayName: "Bob"); await factory.SeedGlobalsAsync(); await factory.SeedDeckAsync(v1, Format.Unlimited, 1); await factory.SeedDeckAsync(v2, Format.Unlimited, 1); // Alice polls first → parks. var c1 = factory.CreateAuthenticatedClient(v1); var r1 = await c1.PostAsJsonAsync("/unlimited_free_battle/do_matching", DoMatchingBody); var j1 = JsonDocument.Parse(await r1.Content.ReadAsStringAsync()).RootElement; Assert.That(j1.GetProperty("matching_state").GetInt32(), Is.EqualTo(3002)); // Bob polls — pairs, returns joiner (3004). var c2 = factory.CreateAuthenticatedClient(v2); var r2 = await c2.PostAsJsonAsync("/unlimited_free_battle/do_matching", DoMatchingBody); var j2 = JsonDocument.Parse(await r2.Content.ReadAsStringAsync()).RootElement; Assert.That(j2.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004), "Joiner = 3004."); Assert.That(j2.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty); Assert.That(j2.GetProperty("node_server_url").GetString(), Is.Not.Empty); // Alice polls again — gets cached match, owner role (3007). var r3 = await c1.PostAsJsonAsync("/unlimited_free_battle/do_matching", DoMatchingBody); var j3 = JsonDocument.Parse(await r3.Content.ReadAsStringAsync()).RootElement; Assert.That(j3.GetProperty("matching_state").GetInt32(), Is.EqualTo(3007), "Owner = 3007."); Assert.That(j3.GetProperty("battle_id").GetString(), Is.EqualTo(j2.GetProperty("battle_id").GetString())); } [Test] public async Task DoMatching_returns_3001_when_viewer_has_no_deck_for_format_and_slot() { await using var factory = new SVSimTestFactory(); var viewerId = await factory.SeedViewerAsync(); await factory.SeedGlobalsAsync(); // Note: NO SeedDeckAsync. BuildForRankBattleAsync will throw InvalidOperationException. var client = factory.CreateAuthenticatedClient(viewerId); var resp = await client.PostAsJsonAsync("/unlimited_free_battle/do_matching", DoMatchingBody); Assert.That(resp.IsSuccessStatusCode, Is.True); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); var data = doc.RootElement; Assert.That(data.GetProperty("matching_state").GetInt32(), Is.EqualTo(3001), "Missing deck → RC_BATTLE_MATCHING_ILLEGAL."); Assert.That(data.GetProperty("node_server_url").GetString(), Is.EqualTo("")); } [Test] public async Task Finish_emits_battle_result_echo_and_four_field_minimum() { await using var factory = new SVSimTestFactory(); var viewerId = await factory.SeedViewerAsync(); var client = factory.CreateAuthenticatedClient(viewerId); var resp = await client.PostAsJsonAsync("/unlimited_free_battle/finish", FinishBody(battleResult: 1)); Assert.That(resp.IsSuccessStatusCode, Is.True); var raw = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(raw); var data = doc.RootElement; Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1)); Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(0)); Assert.That(data.GetProperty("class_experience").GetInt32(), Is.EqualTo(0)); Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(1)); // Strict subset — no rank fields. The client doesn't read them on free-battle // finish; emitting them would be wire-format pollution. Assert.That(raw, Does.Not.Contain("\"rank\"")); Assert.That(raw, Does.Not.Contain("\"after_battle_point\"")); Assert.That(raw, Does.Not.Contain("\"after_master_point\"")); Assert.That(raw, Does.Not.Contain("\"successive_win")); } [Test] public async Task Finish_with_consistency_result_echoes_2_on_rotation_url() { await using var factory = new SVSimTestFactory(); var viewerId = await factory.SeedViewerAsync(); var client = factory.CreateAuthenticatedClient(viewerId); var resp = await client.PostAsJsonAsync("/rotation_free_battle/finish", FinishBody(battleResult: 2)); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2)); } [Test] public async Task ForceFinish_returns_200_with_empty_object() { await using var factory = new SVSimTestFactory(); var viewerId = await factory.SeedViewerAsync(); var client = factory.CreateAuthenticatedClient(viewerId); var resp = await client.PostAsJsonAsync("/free_battle/force_finish", EmptyAuthedBody); Assert.That(resp.IsSuccessStatusCode, Is.True); var raw = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(raw); Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Object)); // Empty object: no enumerable properties. Assert.That(doc.RootElement.EnumerateObject().MoveNext(), Is.False, "Body should be {} — defensive no-op for dead URL."); } [Test] public async Task DoMatching_unlimited_accepts_literal_prod_request_keys() { await using var factory = new SVSimTestFactory(); var viewerId = await factory.SeedViewerAsync(); await factory.SeedGlobalsAsync(); await factory.SeedDeckAsync(viewerId, Format.Unlimited, 7); var client = factory.CreateAuthenticatedClient(viewerId); // Body shape lifted from a real prod capture (traffic_prod_ranked_unlimited.ndjson // line 73). Exercises every wire key the client emits on this URL. var literalProdBody = new { deck_no = 7, need_init = 0, log = 2, excluded_field_id_list = Array.Empty(), use_stage_select = 1, is_default_skin = 0, // Auth placeholders — TestAuthHandler injects the real viewer-id via header. viewer_id = "0", steam_id = 0, steam_session_ticket = "", }; var resp = await client.PostAsJsonAsync("/unlimited_free_battle/do_matching", literalProdBody); Assert.That(resp.IsSuccessStatusCode, Is.True, $"Expected 2xx, got {resp.StatusCode}"); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); var data = doc.RootElement; // First poll on an empty queue → 3002 RETRY. Assert.That(data.GetProperty("matching_state").GetInt32(), Is.EqualTo(3002)); Assert.That(data.GetProperty("node_server_url").GetString(), Is.EqualTo("")); // timeout_period + retry_period are always present. Assert.That(data.TryGetProperty("timeout_period", out _), Is.True); Assert.That(data.TryGetProperty("retry_period", out _), Is.True); } }