diff --git a/SVSim.UnitTests/Controllers/GiftControllerTests.cs b/SVSim.UnitTests/Controllers/GiftControllerTests.cs index 5f29068..af953eb 100644 --- a/SVSim.UnitTests/Controllers/GiftControllerTests.cs +++ b/SVSim.UnitTests/Controllers/GiftControllerTests.cs @@ -379,4 +379,154 @@ public class GiftControllerTests { "71478626", "71478627", "71478628", "71478629", "71478630" })); } } + + [Test] + public async Task GiftReceive_with_card_reward_grants_card_via_inventory_service() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 100); + long cardId = await factory.SeedCardAsync(); + + // Seed a single non-tutorial Card present. + using (var seedScope = factory.Services.CreateScope()) + { + var ctx = seedScope.ServiceProvider.GetRequiredService(); + ctx.ViewerPresents.Add(new ViewerPresent + { + ViewerId = viewerId, + PresentId = "card-gift-001", + Status = PresentStatus.Unclaimed, + RewardType = 5, // UserGoodsType.Card + RewardDetailId = cardId, + RewardCount = 1, + Message = "Test card grant", + CreatedAt = DateTime.UtcNow, + Source = "test", + }); + await ctx.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = $$"""{"present_id_array":["card-gift-001"],"state":1,{{BaseAuthBlock}}}"""; + var response = await client.PostAsync("/gift/receive_gift", + new StringContent(json, Encoding.UTF8, "application/json")); + var bodyStr = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), bodyStr); + + // Verify the card landed in the viewer's collection. + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + var owned = await ctx2.Viewers.AsNoTracking() + .Include(v => v.Cards).ThenInclude(c => c.Card) + .FirstAsync(v => v.Id == viewerId); + Assert.That(owned.Cards.Any(c => c.Card.Id == cardId && c.Count > 0), Is.True, + "Card reward_type=5 must round-trip through the gift mapper and InventoryService.GrantAsync."); + } + + [Test] + public async Task GiftReceive_with_sleeve_reward_grants_sleeve_via_inventory_service() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 100); + + const int sleeveId = 700100; + using (var seedScope = factory.Services.CreateScope()) + { + var ctx = seedScope.ServiceProvider.GetRequiredService(); + ctx.Sleeves.Add(new SleeveEntry { Id = sleeveId }); + await ctx.SaveChangesAsync(); + + ctx.ViewerPresents.Add(new ViewerPresent + { + ViewerId = viewerId, + PresentId = "sleeve-gift-001", + Status = PresentStatus.Unclaimed, + RewardType = 6, // UserGoodsType.Sleeve + RewardDetailId = sleeveId, + RewardCount = 1, + Message = "Test sleeve grant", + CreatedAt = DateTime.UtcNow, + Source = "test", + }); + await ctx.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = $$"""{"present_id_array":["sleeve-gift-001"],"state":1,{{BaseAuthBlock}}}"""; + var response = await client.PostAsync("/gift/receive_gift", + new StringContent(json, Encoding.UTF8, "application/json")); + var bodyStr = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), bodyStr); + + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + var owned = await ctx2.Viewers.AsNoTracking() + .Include(v => v.Sleeves) + .FirstAsync(v => v.Id == viewerId); + Assert.That(owned.Sleeves.Any(s => s.Id == sleeveId), Is.True, + "Sleeve reward_type=6 must round-trip through the gift mapper and InventoryService.GrantAsync."); + } + + [Test] + public async Task GiftReceive_with_unsupported_reward_type_does_not_grant() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(tutorialState: 100); + + // RewardType 11 = SpotCard, which GiftRewardTypes.IsSupported rejects, causing + // WireRewardTypeToUserGoodsType to throw InvalidOperationException before CommitAsync. + using (var seedScope = factory.Services.CreateScope()) + { + var ctx = seedScope.ServiceProvider.GetRequiredService(); + ctx.ViewerPresents.Add(new ViewerPresent + { + ViewerId = viewerId, + PresentId = "bad-gift-001", + Status = PresentStatus.Unclaimed, + RewardType = 11, + RewardDetailId = 123, + RewardCount = 1, + Message = "Bad type", + CreatedAt = DateTime.UtcNow, + Source = "test", + }); + await ctx.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = $$"""{"present_id_array":["bad-gift-001"],"state":1,{{BaseAuthBlock}}}"""; + + // TestServer propagates unhandled controller exceptions through HttpClient.PostAsync + // (because Program.cs has no app.UseExceptionHandler middleware). Catch the exception + // here; the critical assertion is that the DB row was never transitioned. + bool threw = false; + try + { + var response = await client.PostAsync("/gift/receive_gift", + new StringContent(json, Encoding.UTF8, "application/json")); + // If exception handling middleware is added later, the response will be 500 — accept both. + Assert.That((int)response.StatusCode, Is.GreaterThanOrEqualTo(500), + "Unsupported reward_type must not return a success status code."); + } + catch (Exception ex) when (ex.Message.Contains("Unsupported gift reward_type") || + ex.InnerException?.Message.Contains("Unsupported gift reward_type") == true) + { + threw = true; + } + + // Either path (thrown exception or 5xx) is acceptable; what matters is the DB row stayed Unclaimed. + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + var present = await ctx2.ViewerPresents.AsNoTracking() + .FirstAsync(p => p.PresentId == "bad-gift-001"); + Assert.That(present.Status, Is.EqualTo(PresentStatus.Unclaimed), + "Failed claim must NOT transition the row — it's still claimable once the producer is fixed."); + + // Confirm the exception did propagate (not swallowed into a silent 200). + Assert.That(threw, Is.True, + "InvalidOperationException for unsupported reward_type must propagate — not be silently swallowed into a 200."); + } }