using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; using SVSim.Database.Enums; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Controllers; /// /// End-to-end coverage for /admin/import_viewer. The endpoint is [AllowAnonymous] so /// these tests don't need to seed a viewer first; the fresh-user path exercises the just-fixed /// nav-graph NRE inside ViewerRepository.RegisterViewer, and the existing-user path /// exercises the owned-type lookup used to dedupe by Steam id. /// public class AdminControllerTests { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; [Test] public async Task ImportViewer_fresh_user_creates_viewer_and_returns_ids() { using var factory = new SVSimTestFactory(); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = 76_561_198_222_333_444UL, DisplayName = "Fresh User", CountryCode = "USA", TutorialState = 100, Currency = new ImportCurrency { Crystals = 12345 } }); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); var body = await response.Content.ReadFromJsonAsync(JsonOptions); Assert.That(body, Is.Not.Null); Assert.That(body!.ViewerId, Is.GreaterThan(0), "RegisterViewer must persist and return a non-zero id."); Assert.That(body.WasCreated, Is.True); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Viewers .Include(v => v.SocialAccountConnections) .Include(v => v.Currency) .Include(v => v.Info) .FirstAsync(v => v.Id == body.ViewerId); Assert.That(stored.DisplayName, Is.EqualTo("Fresh User")); Assert.That(stored.Currency.Crystals, Is.EqualTo(12345UL), "ImportViewer should overwrite the seed-config crystal default with the requested value."); Assert.That(stored.Info.CountryCode, Is.EqualTo("USA")); Assert.That(stored.SocialAccountConnections.Count, Is.EqualTo(1)); Assert.That(stored.SocialAccountConnections[0].AccountId, Is.EqualTo(76_561_198_222_333_444UL)); Assert.That(stored.SocialAccountConnections[0].AccountType, Is.EqualTo(SocialAccountType.Steam)); } [Test] public async Task ImportViewer_existing_user_updates_in_place() { using var factory = new SVSimTestFactory(); const ulong steamId = 76_561_198_555_666_777UL; long seededId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Original Name"); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, DisplayName = "Updated Name", CountryCode = "JPN" }); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); var body = await response.Content.ReadFromJsonAsync(JsonOptions); Assert.That(body, Is.Not.Null); Assert.That(body!.ViewerId, Is.EqualTo(seededId), "Re-importing the same SteamId must reuse the existing viewer row, not create a new one."); Assert.That(body.WasCreated, Is.False); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Viewers .Include(v => v.Info) .FirstAsync(v => v.Id == seededId); Assert.That(stored.DisplayName, Is.EqualTo("Updated Name")); Assert.That(stored.Info.CountryCode, Is.EqualTo("JPN")); var viewerCount = await db.Viewers.CountAsync(v => v.SocialAccountConnections.Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId)); Assert.That(viewerCount, Is.EqualTo(1), "Owned-type dedup must not produce a second row."); } [Test] public async Task ImportViewer_missing_steam_id_returns_400() { using var factory = new SVSimTestFactory(); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = 0, DisplayName = "No Steam" }); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); } [Test] public async Task ImportViewer_imports_owned_cards_and_skips_unknown() { using var factory = new SVSimTestFactory(); using var client = factory.CreateClient(); // 10001001 is in the minimal test card set; 99999999 is not. var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = 76_561_198_111_222_333UL, OwnedCards = new List { new() { CardId = 10001001L, Count = 2, IsProtected = true }, new() { CardId = 99999999L, Count = 1, IsProtected = false }, } }); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); var body = await response.Content.ReadFromJsonAsync(JsonOptions); Assert.That(body!.SkippedCardCount, Is.EqualTo(1), "Unknown 99999999 must be skipped and counted."); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card) .FirstAsync(v => v.Id == body.ViewerId); Assert.That(stored.Cards.Count, Is.EqualTo(1), "Only the known card should be stored."); var owned = stored.Cards.Single(); Assert.That(owned.Card.Id, Is.EqualTo(10001001L)); Assert.That(owned.Count, Is.EqualTo(2)); Assert.That(owned.IsProtected, Is.True); } [Test] public async Task ImportViewer_clamps_card_count_to_max_copies() { using var factory = new SVSimTestFactory(); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = 76_561_198_111_222_334UL, OwnedCards = new List { new() { CardId = 10001002L, Count = 5 } } }); var body = await response.Content.ReadFromJsonAsync(JsonOptions); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card) .FirstAsync(v => v.Id == body!.ViewerId); Assert.That(stored.Cards.Single().Count, Is.EqualTo(3), "Count must clamp to OwnedCardEntry.MaxCopies (3)."); } [Test] public async Task ImportViewer_replaces_existing_card_collection() { using var factory = new SVSimTestFactory(); const ulong steamId = 76_561_198_111_222_335UL; long viewerId = await factory.SeedViewerAsync(steamId: steamId); await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, OwnedCards = new List { new() { CardId = 10001002L, Count = 1 } } }); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card) .FirstAsync(v => v.Id == viewerId); Assert.That(stored.Cards.Select(c => c.Card.Id), Is.EquivalentTo(new[] { 10001002L }), "Full replace: the pre-seeded 10001001 must be gone, only 10001002 present."); } [Test] public async Task ImportViewer_imports_items_and_replaces_existing() { using var factory = new SVSimTestFactory(); const ulong steamId = 76_561_198_111_222_336UL; long viewerId = await factory.SeedViewerAsync(steamId: steamId); // Registers the ItemEntry master row (70001) and gives an initial owned count to be replaced. await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 1); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, Items = new List { new() { ItemId = 70001, Count = 5 }, new() { ItemId = 88888, Count = 9 }, // unknown master id -> skipped silently } }); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 70001), Is.EqualTo(5), "Full replace: 70001 count updated to 5."); Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 88888), Is.EqualTo(0), "Unknown item master id must not be inserted."); } [Test] public async Task ImportViewer_imports_deck_with_correct_format_and_skips_unknown_cards() { using var factory = new SVSimTestFactory(); const ulong steamId = 76_561_198_111_222_337UL; long viewerId = await factory.SeedViewerAsync(steamId: steamId); int classId, leaderSkinId; long sleeveId; using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); classId = (await db.Classes.FirstAsync()).Id; sleeveId = (await db.Sleeves.FirstAsync()).Id; leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id; } using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, Decks = new List { new() { DeckFormat = 1, // wire Rotation DeckNo = 1, DeckName = "Imported Rotation", ClassId = classId, SleeveId = sleeveId, LeaderSkinId = leaderSkinId, CardIdArray = new List { 10001001L, 10001001L, 99999999L }, // last is unknown } } }); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); var body = await response.Content.ReadFromJsonAsync(JsonOptions); Assert.That(body!.SkippedCardCount, Is.EqualTo(1), "Unknown deck card 99999999 counts as skipped."); using var scope2 = factory.Services.CreateScope(); var db2 = scope2.ServiceProvider.GetRequiredService(); var stored = await db2.Viewers .Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card) .FirstAsync(v => v.Id == viewerId); var deck = stored.Decks.Single(d => d.Name == "Imported Rotation"); Assert.That(deck.Format, Is.EqualTo(Format.Rotation)); Assert.That(deck.Cards.Single().Card.Id, Is.EqualTo(10001001L)); Assert.That(deck.Cards.Single().Count, Is.EqualTo(2), "Two copies of 10001001 grouped."); } [Test] public async Task ImportViewer_myrotation_deck_gets_rotation_id() { using var factory = new SVSimTestFactory(); await factory.SeedGlobalsAsync(); // populates MyRotationSettings const ulong steamId = 76_561_198_111_222_338UL; long viewerId = await factory.SeedViewerAsync(steamId: steamId); int classId, leaderSkinId; long sleeveId; using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); classId = (await db.Classes.FirstAsync()).Id; sleeveId = (await db.Sleeves.FirstAsync()).Id; leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id; } using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = steamId, Decks = new List { new() { DeckFormat = 5, // wire MyRotation DeckNo = 1, DeckName = "Imported MyRot", ClassId = classId, SleeveId = sleeveId, LeaderSkinId = leaderSkinId, CardIdArray = new List { 10001001L }, } } }); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), await response.Content.ReadAsStringAsync()); var body = await response.Content.ReadFromJsonAsync(JsonOptions); using var scope2 = factory.Services.CreateScope(); var db2 = scope2.ServiceProvider.GetRequiredService(); var deck = await db2.Set() .FirstAsync(d => d.Name == "Imported MyRot"); Assert.That(deck.Format, Is.EqualTo(Format.MyRotation)); Assert.That(deck.MyRotationId, Is.Not.Null.And.Not.Empty, "MyRotation decks need a rotation id or the client NREs on click."); } [Test] public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported() { using var factory = new SVSimTestFactory(); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest { SteamId = 76_561_198_111_222_339UL, DisplayName = "No Decks" }); var body = await response.Content.ReadFromJsonAsync(JsonOptions); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == body!.ViewerId); Assert.That(stored.Decks, Is.Empty, "Default-deck cloning was removed; a fresh viewer with no imported decks has none."); } }