A real /load/index dump emits my_rotation_id as a bare number (0) for unset MyRotation slots, which 400'd against the string? DTO field (AllowReadingFromString only covers string->number). FlexibleStringConverter accepts either form. Also skip empty deck slots (no cards) on import — a dump carries every slot, mostly empty placeholders the client manages itself. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
454 lines
20 KiB
C#
454 lines
20 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.");
|
|
}
|
|
|
|
[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<ImportItem>
|
|
{
|
|
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<SVSimDbContext>();
|
|
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<ImportDeck>
|
|
{
|
|
new()
|
|
{
|
|
DeckFormat = 1, // wire Rotation
|
|
DeckNo = 1,
|
|
DeckName = "Imported Rotation",
|
|
ClassId = classId,
|
|
SleeveId = sleeveId,
|
|
LeaderSkinId = leaderSkinId,
|
|
CardIdArray = new List<long> { 10001001L, 10001001L, 99999999L }, // last is unknown
|
|
}
|
|
}
|
|
});
|
|
|
|
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 deck card 99999999 counts as skipped.");
|
|
|
|
using var scope2 = factory.Services.CreateScope();
|
|
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
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<SVSimDbContext>();
|
|
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<ImportDeck>
|
|
{
|
|
new()
|
|
{
|
|
DeckFormat = 5, // wire MyRotation
|
|
DeckNo = 1,
|
|
DeckName = "Imported MyRot",
|
|
ClassId = classId,
|
|
SleeveId = sleeveId,
|
|
LeaderSkinId = leaderSkinId,
|
|
CardIdArray = new List<long> { 10001001L },
|
|
}
|
|
}
|
|
});
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
|
await response.Content.ReadAsStringAsync());
|
|
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
|
|
|
using var scope2 = factory.Services.CreateScope();
|
|
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var deck = await db2.Set<SVSim.Database.Models.ShadowverseDeckEntry>()
|
|
.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<ImportViewerResponse>(JsonOptions);
|
|
|
|
using var scope = factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
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.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ImportViewer_binds_new_fields_from_literal_client_json()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
const ulong steamId = 76_561_198_111_222_340UL;
|
|
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
|
await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 0); // register item master
|
|
|
|
int classId, leaderSkinId; long sleeveId;
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
classId = (await db.Classes.FirstAsync()).Id;
|
|
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
|
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
|
}
|
|
|
|
string json = $$"""
|
|
{
|
|
"steam_id": {{steamId}},
|
|
"owned_cards": [ { "card_id": 10001001, "count": 2, "is_protected": true } ],
|
|
"items": [ { "item_id": 70001, "count": 4 } ],
|
|
"decks": [ {
|
|
"deck_format": 1,
|
|
"deck_no": 2,
|
|
"deck_name": "Wire Deck",
|
|
"class_id": {{classId}},
|
|
"sleeve_id": {{sleeveId}},
|
|
"leader_skin_id": {{leaderSkinId}},
|
|
"is_random_leader_skin": 0,
|
|
"card_id_array": [10001001, 10001002]
|
|
} ]
|
|
}
|
|
""";
|
|
|
|
using var client = factory.CreateClient();
|
|
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
|
var response = await client.PostAsync("/admin/import_viewer", content);
|
|
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
|
await response.Content.ReadAsStringAsync());
|
|
|
|
using var scope2 = factory.Services.CreateScope();
|
|
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var stored = await db2.Viewers
|
|
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
|
.Include(v => v.Decks)
|
|
.FirstAsync(v => v.Id == viewerId);
|
|
|
|
Assert.That(stored.Cards.Any(c => c.Card.Id == 10001001L && c.Count == 2 && c.IsProtected), Is.True,
|
|
"owned_cards snake_case keys must bind (card_id/count/is_protected).");
|
|
Assert.That(stored.Items.Any(i => i.Item.Id == 70001 && i.Count == 4), Is.True,
|
|
"items snake_case keys must bind (item_id/count).");
|
|
Assert.That(stored.Decks.Any(d => d.Name == "Wire Deck" && d.Format == Format.Rotation), Is.True,
|
|
"decks snake_case keys must bind (deck_format/deck_no/class_id/card_id_array/...).");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ImportViewer_tolerates_numeric_my_rotation_id_and_skips_empty_decks()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
const ulong steamId = 76_561_198_111_222_341UL;
|
|
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
|
|
|
int classId, leaderSkinId; long sleeveId;
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
classId = (await db.Classes.FirstAsync()).Id;
|
|
sleeveId = (await db.Sleeves.FirstAsync()).Id;
|
|
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
|
}
|
|
|
|
// Mirrors a real prod dump: empty MyRotation slots carry "my_rotation_id": 0 (a NUMBER,
|
|
// not a string), and dozens of empty slots accompany the few real decks.
|
|
string json = $$"""
|
|
{
|
|
"steam_id": {{steamId}},
|
|
"decks": [
|
|
{ "deck_format": 2, "deck_no": 1, "deck_name": "Real", "class_id": {{classId}},
|
|
"sleeve_id": {{sleeveId}}, "leader_skin_id": {{leaderSkinId}},
|
|
"is_random_leader_skin": 0, "card_id_array": [10001001, 10001002] },
|
|
{ "deck_format": 5, "deck_no": 1, "deck_name": "", "class_id": 0,
|
|
"sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0,
|
|
"my_rotation_id": 0, "card_id_array": [] },
|
|
{ "deck_format": 1, "deck_no": 3, "deck_name": "", "class_id": 1,
|
|
"sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0,
|
|
"card_id_array": [] }
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var client = factory.CreateClient();
|
|
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
|
var response = await client.PostAsync("/admin/import_viewer", content);
|
|
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
|
await response.Content.ReadAsStringAsync());
|
|
|
|
using var scope2 = factory.Services.CreateScope();
|
|
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var stored = await db2.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == viewerId);
|
|
Assert.That(stored.Decks.Count, Is.EqualTo(1),
|
|
"Empty deck slots must be skipped; only the real (non-empty) deck imports.");
|
|
Assert.That(stored.Decks.Single().Name, Is.EqualTo("Real"));
|
|
}
|
|
}
|