diff --git a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs index 9d97a99..d0250c4 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GiftController.cs @@ -77,8 +77,14 @@ public class GiftController : SVSimController // the pattern in TutorialController.Update and to make the intent clear. // AsSplitQuery is the default-safe pattern when including viewer collections // (project memory: project_ef_split_query). + // + // ThenInclude(i => i.Item) is load-bearing: OwnedItemEntry.Item is a separate non-owned + // entity whose default initialiser is `new ItemEntry()` (Id=0). Without the explicit + // ThenInclude, RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == ...)` + // never matches a pre-existing row → falls through to add a duplicate → (ViewerId, ItemId) + // unique index throws on SaveChanges (project_ef_nav_include_pitfall). var viewer = await _db.Viewers - .Include(v => v.Items) + .Include(v => v.Items).ThenInclude(i => i.Item) .Include(v => v.MissionData) .AsSplitQuery() .FirstAsync(v => v.Id == viewerId); diff --git a/SVSim.UnitTests/Controllers/GiftControllerTests.cs b/SVSim.UnitTests/Controllers/GiftControllerTests.cs index 1e9f543..8f779fa 100644 --- a/SVSim.UnitTests/Controllers/GiftControllerTests.cs +++ b/SVSim.UnitTests/Controllers/GiftControllerTests.cs @@ -119,6 +119,45 @@ public class GiftControllerTests Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41)); } + [Test] + public async Task GiftReceive_with_pre_owned_item_increments_existing_row() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 31); + + // Seed item 1 (= the 3-item gift's reward_detail_id) with count=5 pre-existing. + // Any non-tutorial source could leave a viewer here — battlepass, future reward, + // admin import. Gift 71478628 grants +3 of item 1; the existing row must be + // found and incremented, not duplicated. The (ViewerId, ItemId) unique index + // added 2026-05-25 would otherwise throw on SaveChanges → 500 to the client. + await factory.SeedOwnedItemAsync(viewerId, itemId: 1, count: 5, itemName: "PreOwnedItem"); + + using var client = factory.CreateAuthenticatedClient(viewerId); + + var json = $$"""{"present_id_array":["71478628"],"state":1,{{BaseAuthBlock}}}"""; + var response = await client.PostAsync("/tutorial/gift_receive", + new StringContent(json, Encoding.UTF8, "application/json")); + + var bodyStr = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), bodyStr); + + // Existing row was incremented to 8 (5 + 3), not duplicated. + Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 1), Is.EqualTo(8), + "Pre-existing OwnedItemEntry must be found via the ThenIncluded Item nav; " + + "otherwise RewardGrantService falls through to add a new row and the " + + "(ViewerId, ItemId) unique index throws on SaveChanges."); + + // reward_list reflects the post-state total (8), not the gift delta (3). + using var doc = JsonDocument.Parse(bodyStr); + var itemEntry = doc.RootElement.GetProperty("reward_list").EnumerateArray() + .First(e => e.GetProperty("reward_type").GetString() == "4" + && e.GetProperty("reward_id").GetString() == "1"); + Assert.That(itemEntry.GetProperty("reward_num").GetString(), Is.EqualTo("8"), + "RewardNum carries the POST-STATE TOTAL — client direct-assigns it onto the " + + "cached count, so emitting the delta would clobber on-screen inventory."); + } + [Test] public async Task GiftReceive_second_call_with_same_ids_does_not_double_grant() {