fix(pack): /pack/info reads ItemId via shadow FK, not nav property
PackController.Info's ownedItemsByItemId projection used `i.Item.Id` to key the dict — EF translates that to the FK column today, but any future model change that breaks the nav→column mapping would fall back to client eval and collapse every key to 0 (the default Item constructor's Id), silently hiding every tutorial pack via item_number=0. EF.Property<int> reads the shadow FK directly and is robust to nav changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -63,10 +63,18 @@ public class PackController : SVSimController
|
|||||||
// OwnedItemEntry is [Owned] by Viewer, and EF refuses to track owned entities without
|
// OwnedItemEntry is [Owned] by Viewer, and EF refuses to track owned entities without
|
||||||
// their owner in the result. Project to primitive pairs in the database query before
|
// their owner in the result. Project to primitive pairs in the database query before
|
||||||
// materialising into the dictionary — no entity tracking, single round-trip.
|
// materialising into the dictionary — no entity tracking, single round-trip.
|
||||||
|
//
|
||||||
|
// Use EF.Property<int>(i, "ItemId") to read the shadow FK directly instead of going
|
||||||
|
// through the OwnedItemEntry.Item nav. The nav route works today (EF translates
|
||||||
|
// `i.Item.Id` to the FK column), but a future model change that renames the FK or
|
||||||
|
// breaks the nav→column mapping would silently fall back to client eval — where
|
||||||
|
// `i.Item.Id` returns 0 for every row (the default-initialised ItemEntry) and the
|
||||||
|
// dictionary collapses every ticket to item_number=0. Shadow-FK access bypasses
|
||||||
|
// that hazard entirely.
|
||||||
var ownedItemsByItemId = await _db.Viewers
|
var ownedItemsByItemId = await _db.Viewers
|
||||||
.Where(v => v.Id == viewerId)
|
.Where(v => v.Id == viewerId)
|
||||||
.SelectMany(v => v.Items)
|
.SelectMany(v => v.Items)
|
||||||
.Select(i => new { ItemId = (long)i.Item.Id, i.Count })
|
.Select(i => new { ItemId = (long)EF.Property<int>(i, "ItemId"), i.Count })
|
||||||
.ToDictionaryAsync(x => x.ItemId, x => x.Count);
|
.ToDictionaryAsync(x => x.ItemId, x => x.Count);
|
||||||
|
|
||||||
return new PackInfoResponse
|
return new PackInfoResponse
|
||||||
|
|||||||
@@ -12,6 +12,38 @@ namespace SVSim.UnitTests.Controllers;
|
|||||||
|
|
||||||
public class PackControllerTests
|
public class PackControllerTests
|
||||||
{
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task PackInfo_item_number_reflects_owned_ticket_count()
|
||||||
|
{
|
||||||
|
// Verifies the ownedItemsByItemId projection in PackController.Info — the dict that
|
||||||
|
// drives child_gacha_info.item_number. Tutorial flow filters packs by item_number > 0,
|
||||||
|
// so a regression on the projection (e.g. nav-eval collapsing to 0) silently hides
|
||||||
|
// any pack that requires a ticket.
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
|
||||||
|
|
||||||
|
// Seed item 90001 with count 7 — the legendary starter ticket the tutorial gift grants.
|
||||||
|
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 7, itemName: "Starter Legendary Ticket");
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||||
|
var response = await client.PostAsync("/pack/info",
|
||||||
|
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
// Find pack 99047 (the starter legendary) and verify its child gacha reports item_number=7.
|
||||||
|
var pack99047 = doc.RootElement.GetProperty("pack_config_list").EnumerateArray()
|
||||||
|
.First(p => p.GetProperty("parent_gacha_id").GetInt32() == 99047);
|
||||||
|
var childWithTicket = pack99047.GetProperty("child_gacha_info").EnumerateArray()
|
||||||
|
.First(c => c.TryGetProperty("item_id", out var iid) && iid.GetString() == "90001");
|
||||||
|
Assert.That(childWithTicket.GetProperty("item_number").GetInt32(), Is.EqualTo(7),
|
||||||
|
"child_gacha_info.item_number must reflect the viewer's owned count of the gating " +
|
||||||
|
"item; client filters tutorial packs on item_number > 0.");
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task TutorialPackInfo_returns_same_list_as_pack_info()
|
public async Task TutorialPackInfo_returns_same_list_as_pack_info()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user