Files
SVSimServer/SVSim.UnitTests/Controllers/AdminControllerTests.cs
gamer147 71b3c3e19f feat(import): import owned card collection with unknown-card skip
Extends POST /admin/import_viewer to accept owned_cards (snapshot full-replace).
Unknown card_ids are skipped, counted in skipped_card_count on the response, and
logged server-side. Count is clamped to OwnedCardEntry.MaxCopies (3). Also injects
ILogger into AdminController and adds Cards/Items/Decks to the viewer reload graph.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:22:44 -04:00

199 lines
8.6 KiB
C#

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;
/// <summary>
/// End-to-end coverage for <c>/admin/import_viewer</c>. 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 <c>ViewerRepository.RegisterViewer</c>, and the existing-user path
/// exercises the owned-type lookup used to dedupe by Steam id.
/// </summary>
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<ImportViewerResponse>(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<SVSimDbContext>();
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<ImportViewerResponse>(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<SVSimDbContext>();
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<ImportCard>
{
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<ImportViewerResponse>(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<SVSimDbContext>();
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<ImportCard> { new() { CardId = 10001002L, Count = 5 } }
});
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<ImportCard> { 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<SVSimDbContext>();
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.");
}
}