using System.Net; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; /// /// Coverage for POST /tool/signup — the very first request a fresh client makes on boot. /// Spec: docs/api-spec/endpoints/pre-login/tool-signup.md. /// public class ToolControllerTests { private const string SignupBodyJson = """{"device_name":"DESKTOP-ABC","client_type":"PC","os_version":"Windows 10","app_version":"2.4.0","resource_version":"00000000","carrier":""}"""; private static (HttpClient client, Guid udid) MakeClientWithUdid(SVSimTestFactory factory, string sid = "test-sid") { var udid = Guid.NewGuid(); // SessionidMappingMiddleware needs both headers OR we can populate the session service // directly. Populate directly so we don't have to model the encrypted-UDID header // here (Encryption.Decode runs on the encoded header). A non-empty SID is required — // HttpClient strips empty header values, so Request.Headers["SID"] would resolve to // null and GetUdid() would return null (collapsing into the same path as the // no-mapping case below). using (var scope = factory.Services.CreateScope()) { var session = scope.ServiceProvider.GetRequiredService(); session.StoreUdidForSessionId(sid, udid); } var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("SID", sid); return (client, udid); } [Test] public async Task Signup_creates_viewer_with_udid_and_returns_200() { using var factory = new SVSimTestFactory(); var (client, udid) = MakeClientWithUdid(factory); var response = await client.PostAsync("/tool/signup", new StringContent(SignupBodyJson, Encoding.UTF8, "application/json")); var body = await response.Content.ReadAsStringAsync(); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers .Include(v => v.Classes) .Include(v => v.SocialAccountConnections) .FirstOrDefaultAsync(v => v.Udid == udid); Assert.That(viewer, Is.Not.Null, "Signup should have persisted a Viewer keyed on the request UDID."); Assert.That(viewer!.SocialAccountConnections, Is.Empty, "Anonymous signup must not pre-link a social account."); Assert.That(viewer.Classes, Is.Not.Empty, "Default-loadout body should populate Classes (BuildDefaultViewer wiring)."); } [Test] public async Task Signup_is_idempotent_by_udid() { using var factory = new SVSimTestFactory(); var (client, udid) = MakeClientWithUdid(factory); var r1 = await client.PostAsync("/tool/signup", new StringContent(SignupBodyJson, Encoding.UTF8, "application/json")); var r2 = await client.PostAsync("/tool/signup", new StringContent(SignupBodyJson, Encoding.UTF8, "application/json")); Assert.That(r1.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(r2.StatusCode, Is.EqualTo(HttpStatusCode.OK)); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var matches = await db.Viewers.Where(v => v.Udid == udid).CountAsync(); Assert.That(matches, Is.EqualTo(1), "Second signup must reuse the existing Viewer row, not create a duplicate."); } [Test] public async Task Signup_with_no_udid_returns_5xx() { // No SID→UDID mapping installed; the controller should refuse rather than create a // ghost-Empty viewer (which would dedup all broken clients into a single row). // In production this surfaces as 500 (Kestrel converts unhandled exceptions). The // TestHost ClientHandler rethrows by default, so we accept either: a 5xx response, or // the InvalidOperationException propagating out of HttpClient.PostAsync. using var factory = new SVSimTestFactory(); var client = factory.CreateClient(); Exception? thrown = null; HttpResponseMessage? response = null; try { response = await client.PostAsync("/tool/signup", new StringContent(SignupBodyJson, Encoding.UTF8, "application/json")); } catch (Exception ex) { thrown = ex; } if (thrown is null) { Assert.That(response, Is.Not.Null); Assert.That((int)response!.StatusCode, Is.GreaterThanOrEqualTo(500), "Empty/missing UDID is unrecoverable; controller should throw, not 200."); } else { Assert.That(thrown, Is.InstanceOf().Or.InnerException.InstanceOf(), $"Expected InvalidOperationException from controller, got {thrown.GetType().Name}: {thrown.Message}"); } using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); Assert.That(await db.Viewers.AnyAsync(v => v.Udid == Guid.Empty), Is.False, "No ghost-Empty viewer should have been written."); } }