fix(gift): tutorial gift_receive ThenIncludes OwnedItemEntry.Item
Same project_ef_nav_include_pitfall as 27ebb51's tutorial pack_open fix but in the gift path: without .ThenInclude(i => i.Item), the existing OwnedItemEntry's Item nav defaults to a new ItemEntry() (Id=0), so RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == detailId)` misses pre-existing rows. It falls through to add a new entry, and the (ViewerId, ItemId) unique index added 2026-05-25 throws on SaveChanges → 500 to the client, no tutorial advancement, no currency grant. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user