Forgot unversioned xd

This commit is contained in:
gamer147
2026-05-23 14:18:18 -04:00
parent 6b70850b7b
commit bf6ddf5428
46 changed files with 43610 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
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));
}
}

View File

@@ -0,0 +1,117 @@
using System.Net;
using System.Text;
using System.Text.Json;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Coverage for <c>/check/*</c> — the first two endpoints the client hits on boot. The
/// SpecialTitle smoke is duplicated in RoutingSmokeTests for routing-prefix coverage; this
/// test layers shape assertions over the deeper boot-path concern.
/// </summary>
public class CheckControllerTests
{
private const string BaseRequestJson =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":""}""";
private const string GameStartRequestJson =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","appType":0,"campaignData":"","campaignSign":"","campaignUser":0}""";
[Test]
public async Task SpecialTitle_returns_default_title_id()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsync("/check/special_title",
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.GetProperty("titleImageId").GetString(), Is.EqualTo("0"));
}
[Test]
public async Task GameStart_with_authed_viewer_returns_spec_shape()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/check/game_start",
new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
// now_tutorial_step is a STRING on the wire (prod sends "100"); client calls .ToInt().
Assert.That(root.GetProperty("nowTutorialStep").GetString(), Is.EqualTo("100"),
"RegisterViewer's seed-config default sets tutorial_state=100 (tutorial complete).");
Assert.That(root.GetProperty("tosState").GetInt32(), Is.EqualTo(1));
Assert.That(root.GetProperty("policyState").GetInt32(), Is.EqualTo(1));
Assert.That(root.GetProperty("korAuthorityState").GetInt32(), Is.EqualTo(0));
Assert.That(root.GetProperty("tosId").GetInt32(), Is.EqualTo(1));
Assert.That(root.GetProperty("policyId").GetInt32(), Is.EqualTo(1));
Assert.That(root.GetProperty("korAuthorityId").GetInt32(), Is.EqualTo(0));
// Prod-shape fields (not strictly read by GameStartCheckTask.Parse but sent by prod).
Assert.That(root.GetProperty("nowViewerId").GetInt64(), Is.GreaterThan(0));
Assert.That(root.GetProperty("nowName").GetString(), Is.Not.Empty);
Assert.That(root.GetProperty("nowRank").ValueKind, Is.EqualTo(JsonValueKind.Object));
// Steam connection should round-trip into transition_account_data — all three fields
// serialized as strings (matches prod wire shape).
var transitions = root.GetProperty("transitionAccountData");
Assert.That(transitions.ValueKind, Is.EqualTo(JsonValueKind.Array));
Assert.That(transitions.GetArrayLength(), Is.EqualTo(1),
"Seeded viewer has exactly one Steam social account connection.");
Assert.That(transitions[0].GetProperty("socialAccountType").GetString(),
Is.EqualTo(((int)SVSim.Database.Enums.SocialAccountType.Steam).ToString()));
Assert.That(transitions[0].GetProperty("socialAccountId").GetString(), Is.Not.Empty);
Assert.That(transitions[0].GetProperty("connectedViewerId").GetString(), Is.Not.Empty);
}
[Test]
public async Task GameStart_does_not_expose_unsettable_optional_fields()
{
// GameStartCheckTask.Parse uses `Keys.Contains("rewrite_viewer_id")` + `.ToInt()` with
// no null guard, and same for `account_delete_reservation_status` (presence-only check).
// We can't omit nullable properties on the encrypted MessagePack path — the [Key]
// formatter writes them as Nil unconditionally. So these keys must not exist on
// GameStartResponse at all. If a future change re-adds them, this test breaks the build.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/check/game_start",
new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.That(root.TryGetProperty("rewriteViewerId", out _), Is.False,
"rewrite_viewer_id must NOT be present in the response — client NREs on null .ToInt().");
Assert.That(root.TryGetProperty("accountDeleteReservationStatus", out _), Is.False,
"account_delete_reservation_status must NOT be present — presence triggers client behavior.");
}
[Test]
public async Task GameStart_with_no_viewer_returns_401()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsync("/check/game_start",
new StringContent(GameStartRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
}

View File

@@ -0,0 +1,409 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Coverage for <c>/deck/*</c> — the deck-editor CRUD surface. Plain-JSON path; the
/// camelCase'd C# property names are what tests see (see the note on Phase 6 / encrypted
/// pipeline for the msgpack contract).
/// </summary>
public class DeckControllerTests
{
private static string DeckFormatRequestJson(Format f) =>
$$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckFormat":{{(int)f}}}""";
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
private static async Task<(int classId, int sleeveId, int leaderSkinId)> FetchSeededIds(SVSimTestFactory factory)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var cls = await db.Classes.Select(c => c.Id).FirstAsync();
var sleeve = await db.Sleeves.Select(s => s.Id).FirstAsync();
var skin = await db.LeaderSkins.Select(s => s.Id).FirstAsync();
return (cls, sleeve, skin);
}
// ---- read endpoints ----
[Test]
public async Task MyList_returns_decks_for_format()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, "Slot 1");
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2, "Slot 2");
await factory.SeedDeckAsync(viewerId, Format.Unlimited, 1, "Wrong-format deck");
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/deck/my_list", JsonBody(DeckFormatRequestJson(Format.Rotation)));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var decks = doc.RootElement.GetProperty("userDeckList");
Assert.That(decks.GetArrayLength(), Is.EqualTo(2),
"Only Rotation-format decks should be returned for a Rotation request.");
var names = Enumerable.Range(0, decks.GetArrayLength())
.Select(i => decks[i].GetProperty("name").GetString())
.ToList();
Assert.That(names, Is.EquivalentTo(new[] { "Slot 1", "Slot 2" }));
}
[Test]
public async Task Info_returns_decks_for_format()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Unlimited, 1, "Unlimited Deck");
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/deck/info", JsonBody(DeckFormatRequestJson(Format.Unlimited)));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var decks = doc.RootElement.GetProperty("userDeckList");
Assert.That(decks.GetArrayLength(), Is.EqualTo(1));
Assert.That(decks[0].GetProperty("name").GetString(), Is.EqualTo("Unlimited Deck"));
}
[Test]
public async Task MyList_empty_when_viewer_has_no_decks()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/deck/my_list", JsonBody(DeckFormatRequestJson(Format.Rotation)));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var decks = doc.RootElement.GetProperty("userDeckList");
Assert.That(decks.GetArrayLength(), Is.EqualTo(0));
}
// ---- get_empty_deck_number ----
[Test]
public async Task GetEmptyDeckNumber_returns_1_when_viewer_has_no_decks()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/deck/get_empty_deck_number",
JsonBody(DeckFormatRequestJson(Format.Rotation)));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.GetProperty("emptyDeckNum").GetInt32(), Is.EqualTo(1));
}
[Test]
public async Task GetEmptyDeckNumber_returns_next_free_slot_when_slots_filled()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2);
// Skip slot 3 so the algorithm should hand it back.
await factory.SeedDeckAsync(viewerId, Format.Rotation, 4);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/deck/get_empty_deck_number",
JsonBody(DeckFormatRequestJson(Format.Rotation)));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.GetProperty("emptyDeckNum").GetInt32(), Is.EqualTo(3),
"Algorithm must return the smallest free slot, not just one past the highest used.");
}
// ---- update (create / update / delete) ----
[Test]
public async Task Update_creates_new_deck_when_slot_empty()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
var (classId, sleeveId, leaderSkinId) = await FetchSeededIds(factory);
using var client = factory.CreateAuthenticatedClient(viewerId);
var updateJson = $$"""
{"viewerId":"0","steamId":0,"steamSessionTicket":"",
"deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}},
"isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Fresh Deck",
"isDelete":0,"deckFormat":0}
""";
var response = await client.PostAsync("/deck/update", JsonBody(updateJson));
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<SVSimDbContext>();
var count = await db.Decks.CountAsync(d => d.Number == 1 && d.Format == Format.Rotation);
Assert.That(count, Is.EqualTo(1), "A new deck row should have been inserted.");
var persisted = await db.Decks.FirstAsync(d => d.Number == 1 && d.Format == Format.Rotation);
Assert.That(persisted.Name, Is.EqualTo("Fresh Deck"));
}
[Test]
public async Task Update_updates_existing_deck_in_place()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Original");
var (classId, sleeveId, leaderSkinId) = await FetchSeededIds(factory);
using var client = factory.CreateAuthenticatedClient(viewerId);
var updateJson = $$"""
{"viewerId":"0","steamId":0,"steamSessionTicket":"",
"deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}},
"isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Renamed",
"isDelete":0,"deckFormat":0}
""";
await client.PostAsync("/deck/update", JsonBody(updateJson));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var rows = await db.Decks.Where(d => d.Number == 1 && d.Format == Format.Rotation).ToListAsync();
Assert.That(rows.Count, Is.EqualTo(1), "Update must not insert a duplicate row.");
Assert.That(rows[0].Name, Is.EqualTo("Renamed"));
}
[Test]
public async Task Update_with_is_delete_1_removes_the_slot()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Doomed");
var (classId, sleeveId, leaderSkinId) = await FetchSeededIds(factory);
using var client = factory.CreateAuthenticatedClient(viewerId);
var deleteJson = $$"""
{"viewerId":"0","steamId":0,"steamSessionTicket":"",
"deckNo":1,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}},
"isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":null,
"isDelete":1,"deckFormat":0}
""";
var response = await client.PostAsync("/deck/update", JsonBody(deleteJson));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var still = await db.Decks.AnyAsync(d => d.Number == 1 && d.Format == Format.Rotation);
Assert.That(still, Is.False, "is_delete=1 should remove the row.");
}
[Test]
public async Task Update_returns_refreshed_deck_list()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Existing");
var (classId, sleeveId, leaderSkinId) = await FetchSeededIds(factory);
using var client = factory.CreateAuthenticatedClient(viewerId);
var updateJson = $$"""
{"viewerId":"0","steamId":0,"steamSessionTicket":"",
"deckNo":2,"classId":{{classId}},"leaderSkinId":{{leaderSkinId}},
"isRandomLeaderSkin":false,"sleeveId":{{sleeveId}},"deckName":"Second",
"isDelete":0,"deckFormat":0}
""";
var response = await client.PostAsync("/deck/update", JsonBody(updateJson));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var decks = doc.RootElement.GetProperty("userDeckList");
Assert.That(decks.GetArrayLength(), Is.EqualTo(2),
"/deck/update should hand back the full refreshed list, saving the client a follow-up.");
var names = Enumerable.Range(0, decks.GetArrayLength())
.Select(i => decks[i].GetProperty("name").GetString())
.ToList();
Assert.That(names, Is.EquivalentTo(new[] { "Existing", "Second" }));
}
// ---- single-field mutations ----
[Test]
public async Task UpdateName_persists_and_returns_updated_user_deck()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1, name: "Old Name");
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckName":"New Name","deckFormat":0}""";
var response = await client.PostAsync("/deck/update_name", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.GetProperty("userDeck").GetProperty("name").GetString(),
Is.EqualTo("New Name"));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var deck = await db.Decks.FirstAsync(d => d.Number == 1 && d.Format == Format.Rotation);
Assert.That(deck.Name, Is.EqualTo("New Name"));
}
[Test]
public async Task UpdateSleeve_persists_and_returns_updated_user_deck()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
// Pick a different sleeve than the seed default to prove the change took.
int sleeveId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
sleeveId = await db.Sleeves.OrderByDescending(s => s.Id).Select(s => s.Id).FirstAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"sleeveId":{{sleeveId}},"deckFormat":0}""";
var response = await client.PostAsync("/deck/update_sleeve", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.GetProperty("userDeck").GetProperty("sleeveId").GetInt32(),
Is.EqualTo(sleeveId));
}
[Test]
public async Task UpdateLeaderSkin_persists_and_clears_random_flag()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
int skinId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
skinId = await db.LeaderSkins.OrderByDescending(s => s.Id).Select(s => s.Id).FirstAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = $$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"leaderSkinId":{{skinId}},"deckFormat":0}""";
var response = await client.PostAsync("/deck/update_leader_skin", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var userDeck = doc.RootElement.GetProperty("userDeck");
Assert.That(userDeck.GetProperty("leaderSkinId").GetInt32(), Is.EqualTo(skinId));
Assert.That(userDeck.GetProperty("isRandomLeaderSkin").GetInt32(), Is.EqualTo(0),
"Selecting a specific leader skin clears the random-skin flag.");
}
[Test]
public async Task UpdateRandomLeaderSkin_picks_from_pool_and_persists()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
List<int> pool;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
pool = await db.LeaderSkins.OrderBy(s => s.Id).Take(3).Select(s => s.Id).ToListAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var json =
$$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckFormat":0,"leaderSkinIdList":[{{string.Join(',', pool)}}]}""";
var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var userDeck = doc.RootElement.GetProperty("userDeck");
Assert.That(pool, Contains.Item(userDeck.GetProperty("leaderSkinId").GetInt32()),
"Chosen skin must come from the supplied pool.");
Assert.That(userDeck.GetProperty("isRandomLeaderSkin").GetInt32(), Is.EqualTo(1));
}
[Test]
public async Task UpdateRandomLeaderSkin_rejects_empty_pool_with_400()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"deckFormat":0,"leaderSkinIdList":[]}""";
var response = await client.PostAsync("/deck/update_random_leader_skin", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task UpdateOrder_returns_200()
{
// No persistence today (slot Number doubles as display order); just confirm the
// endpoint round-trips so a future ordering schema doesn't silently regress 200→500.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckOrder":[2,1],"deckFormat":0}""";
var response = await client.PostAsync("/deck/update_order", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
[Test]
public async Task DeleteDeckList_removes_listed_slots()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, 1);
await factory.SeedDeckAsync(viewerId, Format.Rotation, 2);
await factory.SeedDeckAsync(viewerId, Format.Rotation, 3);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNoList":[1,3],"deckFormat":0}""";
var response = await client.PostAsync("/deck/delete_deck_list", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var remaining = await db.Decks.Where(d => d.Format == Format.Rotation)
.Select(d => d.Number).OrderBy(n => n).ToListAsync();
Assert.That(remaining, Is.EqualTo(new[] { 2 }));
}
[Test]
public async Task SetDeckRedis_returns_200_for_authed_viewer()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"classId":1}""";
var response = await client.PostAsync("/deck/set_deck_redis", JsonBody(json));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}

View File

@@ -0,0 +1,155 @@
using System.Net;
using System.Text;
using System.Text.Json;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Coverage for <c>/load/index</c>. The endpoint hits the heaviest <c>.Include</c> chain in the
/// app (<c>ViewerRepository.GetViewerByShortUdid</c>) and serializes the wide
/// <c>IndexResponse</c> shape — first end-to-end exercise of either against a real EF provider.
/// Shape assertions are split per test so a single regression pinpoints one named expectation.
/// </summary>
public class LoadControllerTests
{
private const string IndexRequestJson =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","carrier":"steam","cardMasterHash":""}""";
/// <summary>
/// JSON keys (camelCased C# property names) for fields the client reads unconditionally.
/// These come from the plain-JSON path; the wire-format snake_case keys
/// (<c>user_rank</c>, <c>rotation_card_set_id_list</c>, ...) only apply when the
/// encrypted msgpack pipeline is in play — see <c>EncryptedPipelineTests</c> (Phase 6).
/// Missing any of these is a wire-shape regression in either path.
/// </summary>
private static readonly string[] RequiredIndexKeys =
{
"userTutorial", "userInfo", "userCurrency", "userItems",
"userRotationDecks", "userUnlimitedDecks", "userMyRotationDecks",
"userCards", "userClasses", "sleeves", "userEmblems",
"userDegrees", "leaderSkins", "myPageBackgrounds",
"userRankInfo", "userRankedMatches", "dailyLoginBonus", "arenaConfig",
"redEtherOverrides", "maintenanceCards", "arenaInfos", "rankInfo",
"classExp", "loadingTipCardExclusions", "defaultSettings",
"unlimitedBanList", "rotationSets",
"reprintedCards", "spotCards", "featureMaintenances",
"specialCrystalInfos", "openBattlefieldIds", "lootBoxRegulations",
"gatheringInfo", "userConfig", "deckFormat", "cardSetIdForResourceDlView"
};
private static async Task<JsonElement> PostIndexAndReadBody(SVSimTestFactory factory, long viewerId)
{
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/load/index",
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
var doc = JsonDocument.Parse(body);
return doc.RootElement.Clone();
}
[Test]
public async Task Index_with_minimal_viewer_returns_200()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/load/index",
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
}
[Test]
public async Task Index_with_no_auth_header_returns_401()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsync("/load/index",
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
[Test]
public async Task Index_returns_all_required_keys()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
var root = await PostIndexAndReadBody(factory, viewerId);
var missing = RequiredIndexKeys.Where(k => !root.TryGetProperty(k, out _)).ToList();
Assert.That(missing, Is.Empty,
$"Required IndexResponse keys missing: {string.Join(", ", missing)}");
}
[Test]
public async Task Index_rank_info_is_array_not_dict()
{
// Guards the dict-vs-array regression that ate a previous release. Client iterates
// user_rank by index; a dict would silently deserialize as zero entries.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
var root = await PostIndexAndReadBody(factory, viewerId);
Assert.That(root.GetProperty("userRankInfo").ValueKind, Is.EqualTo(JsonValueKind.Array));
}
[Test]
public async Task Index_user_rank_has_five_entries()
{
// Hard-coded format list in LoadController.RankFormats — five entries, one per
// deck_format discriminator. Client indexes by format value; mismatched count
// would point the wrong format at the wrong rank slot.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
var root = await PostIndexAndReadBody(factory, viewerId);
Assert.That(root.GetProperty("userRankInfo").GetArrayLength(), Is.EqualTo(5));
}
[Test]
public async Task Index_rotation_card_set_id_list_has_at_least_two_entries()
{
// LoadDetail.cs:184 unconditionally indexes [1] and [Count-1] — fewer than two
// entries crashes the client at the home screen.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
var root = await PostIndexAndReadBody(factory, viewerId);
Assert.That(root.GetProperty("rotationSets").GetArrayLength(),
Is.GreaterThanOrEqualTo(2));
}
[Test]
public async Task Index_when_viewer_has_no_decks_returns_empty_format_lists()
{
// A freshly-registered viewer has no decks of any format. The three per-format deck
// containers must still be present and empty so the client's iteration is well-formed.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
var root = await PostIndexAndReadBody(factory, viewerId);
foreach (var key in new[] { "userRotationDecks", "userUnlimitedDecks", "userMyRotationDecks" })
{
var container = root.GetProperty(key);
Assert.That(container.ValueKind, Is.EqualTo(JsonValueKind.Object),
$"{key} should be the UserFormatDeckInfo object wrapper, not a raw array.");
var inner = container.GetProperty("userDecks");
Assert.That(inner.ValueKind, Is.EqualTo(JsonValueKind.Array));
Assert.That(inner.GetArrayLength(), Is.EqualTo(0),
$"{key}.userDecks must be an empty array for a deckless viewer, not null.");
}
}
}

View File

@@ -0,0 +1,120 @@
using System.Net;
using System.Text;
using System.Text.Json;
using SVSim.Database.Enums;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Coverage for <c>/practice/*</c>. The solo-battle subsystem is mostly stubbed (no XP,
/// no missions, no rewards) but the endpoints must still round-trip successfully or the
/// solo-play UI breaks before reaching the battle screen.
/// </summary>
public class PracticeControllerTests
{
private const string BaseRequestJson =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":""}""";
private static string DeckFormatRequestJson(Format f) =>
$$"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckFormat":{{(int)f}}}""";
[Test]
public async Task Info_returns_non_empty_opponent_array()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/practice/info",
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Array),
"/practice/info returns a bare array (no wrapper object) per spec.");
Assert.That(doc.RootElement.GetArrayLength(), Is.GreaterThan(0));
Assert.That(doc.RootElement[0].GetProperty("practiceId").GetInt32(), Is.GreaterThan(0));
}
[Test]
public async Task DeckList_returns_viewer_decks()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1, name: "Rotation Deck");
await factory.SeedDeckAsync(viewerId, Format.Unlimited, number: 1, name: "Unlimited Deck");
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/practice/deck_list",
new StringContent(DeckFormatRequestJson(Format.All), Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var rotation = doc.RootElement.GetProperty("userDeckRotation");
var unlimited = doc.RootElement.GetProperty("userDeckUnlimited");
Assert.That(rotation.GetArrayLength(), Is.EqualTo(1));
Assert.That(rotation[0].GetProperty("name").GetString(), Is.EqualTo("Rotation Deck"));
Assert.That(unlimited.GetArrayLength(), Is.EqualTo(1));
Assert.That(unlimited[0].GetProperty("name").GetString(), Is.EqualTo("Unlimited Deck"));
}
[Test]
public async Task DeckList_empty_when_viewer_has_none()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/practice/deck_list",
new StringContent(DeckFormatRequestJson(Format.All), Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.GetProperty("userDeckRotation").GetArrayLength(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("userDeckUnlimited").GetArrayLength(), Is.EqualTo(0));
}
[Test]
public async Task Start_returns_200()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/practice/start",
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
[Test]
public async Task Finish_accepts_any_recovery_data_returns_zero_xp()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
// recoveryData is an opaque JSON blob serialized to string by the client; the server
// is supposed to accept it without validation. Anything goes.
var finishJson =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":"","deckNo":1,"isWin":1,"evolveCount":2,"totalTurn":5,"enemyClassId":3,"difficulty":1,"deckFormat":0,"classId":1,"recoveryData":"{\"opaque\":\"blob\"}"}""";
var response = await client.PostAsync("/practice/finish",
new StringContent(finishJson, Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.GetProperty("getClassExperience").GetInt32(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("classExperience").GetInt32(), Is.EqualTo(0));
Assert.That(doc.RootElement.GetProperty("rewardList").GetArrayLength(), Is.EqualTo(0));
}
}

View File

@@ -0,0 +1,182 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint;
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
namespace SVSim.UnitTests.Infrastructure;
/// <summary>
/// Test host for the EmulatedEntrypoint app. Each instance opens a private SQLite in-memory
/// database, swaps the production DbContext + Steam auth handler for SQLite-friendly +
/// header-driven test versions, and exposes a <see cref="SeedViewerAsync"/> helper for tests
/// to create realistic viewer rows.
/// </summary>
internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection;
private long _nextSeededShortUdid = 400_000_001;
public SVSimTestFactory()
{
// SQLite :memory: lives only as long as a connection is open — keep ours open for the
// factory's lifetime so the DbContext can reattach to the same DB across scopes.
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Tell Program.cs we're in tests so it skips UpdateDatabase() — the Postgres-targeted
// migrations would fail against SQLite. We call EnsureCreated below instead.
builder.UseEnvironment("Testing");
builder.ConfigureTestServices(services =>
{
ReplaceDbContext(services);
ReplaceAuthHandler(services);
});
}
protected override IHost CreateHost(IHostBuilder builder)
{
var host = base.CreateHost(builder);
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.Database.EnsureCreated();
return host;
}
private void ReplaceDbContext(IServiceCollection services)
{
// Production registered DbContextOptions<SVSimDbContext> with the Npgsql provider; tear
// out every related descriptor so AddDbContext below installs a clean SQLite-backed one.
foreach (var descriptor in services
.Where(d => d.ServiceType == typeof(DbContextOptions<SVSimDbContext>)
|| d.ServiceType == typeof(DbContextOptions)
|| d.ServiceType == typeof(SVSimDbContext))
.ToList())
{
services.Remove(descriptor);
}
services.AddDbContext<SVSimDbContext>(opt =>
{
opt.UseSqlite(_connection);
opt.ReplaceService<Microsoft.EntityFrameworkCore.Infrastructure.IModelCustomizer, SqliteFriendlyModelCustomizer>();
});
}
private static void ReplaceAuthHandler(IServiceCollection services)
{
// Production Program.cs registered SteamSessionAuthenticationHandler under the
// "SteamAuthentication" scheme. Drop that scheme from BOTH the SchemeMap and the
// parallel Schemes list (AddScheme writes to both — and the provider iterates the
// list, not the map, so leaving the old builder behind throws "Scheme already exists"
// when it re-adds during provider construction).
services.AddTransient<TestAuthHandler>();
services.PostConfigure<AuthenticationOptions>(opt =>
{
opt.SchemeMap.Remove(SteamAuthenticationConstants.SchemeName, out _);
var schemesList = (IList<AuthenticationSchemeBuilder>)opt.Schemes;
foreach (var stale in schemesList
.Where(s => s.Name == SteamAuthenticationConstants.SchemeName)
.ToList())
{
schemesList.Remove(stale);
}
opt.AddScheme(SteamAuthenticationConstants.SchemeName, b =>
{
b.HandlerType = typeof(TestAuthHandler);
});
});
}
/// <summary>
/// Creates a fully-formed viewer via the real <see cref="IViewerRepository.RegisterViewer"/>
/// path (so the test exercises the same nav-graph wiring real users hit). The viewer's
/// <c>ShortUdid</c> is overwritten to a unique non-zero value because the Postgres sequence
/// is disabled on SQLite — without this every test viewer collides on 0.
/// </summary>
public async Task<long> SeedViewerAsync(
ulong steamId = 76_561_198_000_000_001UL,
string displayName = "Test Viewer")
{
long viewerId;
long shortUdid;
using (var scope = Services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var v = await repo.RegisterViewer(displayName, SocialAccountType.Steam, steamId);
viewerId = v.Id;
shortUdid = Interlocked.Increment(ref _nextSeededShortUdid);
}
// Second scope: assign a real ShortUdid so claim-based lookups in tests have something
// to find (and so per-viewer ShortUdids don't collide across SeedViewerAsync calls).
using (var scope = Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
v.ShortUdid = shortUdid;
await db.SaveChangesAsync();
}
return viewerId;
}
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>
public HttpClient CreateAuthenticatedClient(long viewerId)
{
var client = CreateClient();
client.DefaultRequestHeaders.Add(TestAuthHandler.ViewerIdHeader, viewerId.ToString());
return client;
}
/// <summary>
/// Inserts a deck for the viewer via the real <see cref="IDeckRepository.UpsertDeck"/>
/// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests
/// that need specific ids should hit the DB directly.
/// </summary>
public async Task SeedDeckAsync(long viewerId, Format format, int number, string name = "Test Deck")
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var repo = scope.ServiceProvider.GetRequiredService<IDeckRepository>();
var cls = await db.Classes.FirstAsync();
var sleeve = await db.Sleeves.FirstAsync();
var skin = await db.LeaderSkins.FirstAsync();
await repo.UpsertDeck(viewerId, format, number, d =>
{
d.Name = name;
d.Class = cls;
d.Sleeve = sleeve;
d.LeaderSkin = skin;
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_connection.Dispose();
}
}
}

View File

@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.ValueGeneration;
using SVSim.Database.Models;
namespace SVSim.UnitTests.Infrastructure;
/// <summary>
/// Replaces the default <see cref="ModelCustomizer"/> in tests. After the normal
/// <c>OnModelCreating</c> runs, strips the Postgres sequence the production model declares
/// for <c>Viewer.ShortUdid</c> so EnsureCreated can build the schema against SQLite (which
/// has no sequence support).
/// </summary>
internal class SqliteFriendlyModelCustomizer : ModelCustomizer
{
public SqliteFriendlyModelCustomizer(ModelCustomizerDependencies dependencies) : base(dependencies)
{
}
public override void Customize(ModelBuilder modelBuilder, DbContext context)
{
base.Customize(modelBuilder, context);
modelBuilder.Model.RemoveSequence("ShortUdidSequence");
var shortUdidProperty = modelBuilder.Entity<Viewer>().Property(v => v.ShortUdid).Metadata;
shortUdidProperty.RemoveAnnotation("Relational:DefaultValueSql");
shortUdidProperty.ValueGenerated = ValueGenerated.Never;
AssignClientSideKeyGenerators(modelBuilder.Model);
}
/// <summary>
/// Owned-collection shadow PKs are <c>ValueGenerated.OnAdd</c> with the production model
/// expecting the database to auto-fill (Postgres IDENTITY). On SQLite a composite-PK column
/// is not a ROWID alias, so the DB can't auto-fill it and we get NOT NULL violations. Walk
/// every owned entity and swap any auto-add primary-key property to use an in-process
/// counter instead.
/// </summary>
private static void AssignClientSideKeyGenerators(IMutableModel model)
{
foreach (var entityType in model.GetEntityTypes())
{
if (!entityType.IsOwned()) continue;
foreach (var key in entityType.GetKeys())
{
foreach (var property in key.Properties)
{
if (property.ValueGenerated != ValueGenerated.OnAdd) continue;
if (property.ClrType != typeof(int) && property.ClrType != typeof(long)) continue;
property.SetValueGeneratorFactory((_, _) =>
property.ClrType == typeof(int)
? (ValueGenerator)new MonotonicIntValueGenerator()
: new MonotonicLongValueGenerator());
}
}
}
}
}
internal sealed class MonotonicIntValueGenerator : ValueGenerator<int>
{
private static int _current;
public override bool GeneratesTemporaryValues => false;
public override int Next(EntityEntry entry) => Interlocked.Increment(ref _current);
}
internal sealed class MonotonicLongValueGenerator : ValueGenerator<long>
{
private static long _current;
public override bool GeneratesTemporaryValues => false;
public override long Next(EntityEntry entry) => Interlocked.Increment(ref _current);
}

View File

@@ -0,0 +1,75 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
namespace SVSim.UnitTests.Infrastructure;
/// <summary>
/// Replaces <see cref="SteamSessionAuthenticationHandler"/> in tests. Reads the viewer id from
/// the <c>X-Test-Viewer-Id</c> header, looks the viewer up, and builds the same claim set the
/// real handler would. Registered under the same scheme name so controller <c>[Authorize]</c>
/// attributes resolve without modification.
/// </summary>
internal class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string ViewerIdHeader = "X-Test-Viewer-Id";
private readonly SVSimDbContext _dbContext;
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
SVSimDbContext dbContext)
: base(options, logger, encoder)
{
_dbContext = dbContext;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(ViewerIdHeader, out var raw))
{
return AuthenticateResult.NoResult();
}
if (!long.TryParse(raw.ToString(), out long viewerId))
{
return AuthenticateResult.Fail($"{ViewerIdHeader} is not a valid long.");
}
Viewer? viewer = await _dbContext.Viewers
.AsNoTracking()
.Include(v => v.SocialAccountConnections)
.FirstOrDefaultAsync(v => v.Id == viewerId);
if (viewer is null)
{
return AuthenticateResult.Fail($"No viewer with id {viewerId} — test forgot to seed.");
}
Context.SetViewer(viewer);
var identity = new ClaimsIdentity(SteamAuthenticationConstants.SchemeName);
identity.AddClaim(new Claim(ClaimTypes.Name, viewer.DisplayName));
identity.AddClaim(new Claim(ShadowverseClaimTypes.ShortUdidClaim, viewer.ShortUdid.ToString()));
identity.AddClaim(new Claim(ShadowverseClaimTypes.ViewerIdClaim, viewer.Id.ToString()));
var steamConnection = viewer.SocialAccountConnections.FirstOrDefault();
if (steamConnection is not null)
{
identity.AddClaim(new Claim(SteamAuthenticationConstants.SteamIdClaim, steamConnection.AccountId.ToString()));
}
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SteamAuthenticationConstants.SchemeName);
return AuthenticateResult.Success(ticket);
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Repositories;
/// <summary>
/// Direct tests against <see cref="ViewerRepository"/>. The owned-type lookup in
/// <see cref="ViewerRepository.GetViewerBySocialConnection"/> previously used
/// <c>_dbContext.Set&lt;SocialAccountConnection&gt;()</c> which EF couldn't translate (owned
/// types aren't queryable as a root). This test would have caught the regression.
/// </summary>
public class ViewerRepositoryTests
{
[Test]
public async Task GetViewerBySocialConnection_returns_viewer_when_steam_id_matches()
{
using var factory = new SVSimTestFactory();
const ulong steamId = 76_561_198_111_222_333UL;
long expectedViewerId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Owner");
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
Viewer? found = await repo.GetViewerBySocialConnection(SocialAccountType.Steam, steamId);
Assert.That(found, Is.Not.Null, "Expected to find the seeded viewer by Steam social connection.");
Assert.That(found!.Id, Is.EqualTo(expectedViewerId));
Assert.That(found.DisplayName, Is.EqualTo("Owner"));
}
[Test]
public async Task GetViewerBySocialConnection_returns_null_when_steam_id_does_not_match()
{
using var factory = new SVSimTestFactory();
await factory.SeedViewerAsync(steamId: 76_561_198_111_222_333UL);
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
Viewer? found = await repo.GetViewerBySocialConnection(SocialAccountType.Steam, 76_561_198_999_999_999UL);
Assert.That(found, Is.Null);
}
[Test]
public async Task RegisterViewer_grants_default_leader_skins_to_classes()
{
// Guards the just-fixed nav-graph NRE — RegisterViewer iterates ClassEntry.LeaderSkins
// and needs the .Include to populate them. If the include is lost, this throws inside
// SeedViewerAsync rather than reaching the assertion.
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
Viewer viewer = await db.Viewers
.Include(v => v.Classes).ThenInclude(c => c.LeaderSkin)
.Include(v => v.LeaderSkins)
.FirstAsync(v => v.Id == viewerId);
Assert.That(viewer.Classes, Is.Not.Empty, "RegisterViewer should populate Classes from seed data.");
Assert.That(viewer.Classes.Select(c => c.LeaderSkin).All(s => s is not null), Is.True,
"Every class should have a LeaderSkin assigned (placeholder or real).");
Assert.That(viewer.LeaderSkins, Is.Not.Empty,
"Viewer should own at least one leader skin from class defaults.");
}
}

View File

@@ -0,0 +1,114 @@
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.EmulatedEntrypoint;
namespace SVSim.UnitTests;
/// <summary>
/// Verifies the routing-prefix fix (audit step 5) actually exposes endpoints at the URLs the
/// client calls (no `api/` prefix). Posts plain JSON without UnityPlayer UA so the
/// translation middleware bypasses and we test routing in isolation.
/// </summary>
public class RoutingSmokeTests
{
private sealed class TestFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<SVSimDbContext>));
if (descriptor != null) services.Remove(descriptor);
services.AddDbContext<SVSimDbContext>(opt => opt.UseInMemoryDatabase("RoutingSmoke"));
});
}
}
private const string ValidBaseRequestJson =
"""{"viewerId":"0","steamId":0,"steamSessionTicket":""}""";
[Test]
public async Task CheckSpecialTitle_resolves_to_CheckController()
{
using var factory = new TestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsync("/check/special_title",
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
$"Expected 200 OK at /check/special_title, got {response.StatusCode}. " +
"If 404, the routing prefix fix (audit step 5) didn't take.");
var body = await response.Content.ReadAsStringAsync();
// Plain-JSON path uses camelCase (System.Text.Json default); MessagePack [Key] only applies
// to the Unity-UA encrypted path through ShadowverseTranslationMiddleware.
Assert.That(body, Does.Contain("\"titleImageId\":\"0\""),
"SpecialTitleCheck should return the built-in title id \"0\".");
}
[Test]
public async Task ImportViewer_route_resolves()
{
// /admin/import_viewer is AllowAnonymous so the route should at least be reachable
// (probably returns 400 for missing steam_id with our empty BaseRequest body; we only
// assert routing not deep behavior).
using var factory = new TestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsync("/admin/import_viewer",
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.NotFound),
"/admin/import_viewer didn't resolve to a controller — route registration broken.");
}
[Test]
public async Task ApiPrefixedRoute_returns_404()
{
// The OLD broken path should now 404 — proves we dropped the `api/` prefix cleanly.
using var factory = new TestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsync("/api/check/special_title",
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
// Authenticated endpoints — we don't set up Steam auth in tests, so we just assert the
// route resolves (anything other than 404). Auth-flow integration tests are a separate
// problem — see PLAN.md status-log open item on body re-read.
[TestCase("/practice/info")]
[TestCase("/practice/deck_list")]
[TestCase("/practice/start")]
[TestCase("/practice/finish")]
[TestCase("/deck/my_list")]
[TestCase("/deck/info")]
[TestCase("/deck/update")]
[TestCase("/deck/update_name")]
[TestCase("/deck/update_sleeve")]
[TestCase("/deck/update_leader_skin")]
[TestCase("/deck/update_random_leader_skin")]
[TestCase("/deck/update_order")]
[TestCase("/deck/delete_deck_list")]
[TestCase("/deck/get_empty_deck_number")]
[TestCase("/deck/set_deck_redis")]
public async Task Authenticated_route_resolves(string path)
{
using var factory = new TestFactory();
using var client = factory.CreateClient();
var response = await client.PostAsync(path,
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.NotFound),
$"{path} didn't resolve to a controller — route registration broken.");
}
}