Files
SVSimServer/SVSim.UnitTests/Controllers/PackControllerTests.cs
gamer147 ac077dfc13 fix(pack): tutorial pack_open ThenIncludes OwnedItemEntry.Item
Without .ThenInclude(i => i.Item), the OwnedItemEntry.Item nav defaults to a
new ItemEntry() with Id=0 (project_ef_nav_include_pitfall), so the
FirstOrDefault(i => i.Item.Id == ticketItemId) lookup never matched. The
ticket was never decremented and reward_list omitted the post-state entry —
on the next /tutorial/pack_info the pack stayed visible and the client
re-clicked into plain /pack/open, which 501s on type_detail=5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:15:31 -04:00

132 lines
6.8 KiB
C#

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.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class PackControllerTests
{
[Test]
public async Task TutorialPackInfo_returns_same_list_as_pack_info()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var direct = await client.PostAsync("/pack/info", new StringContent(json, Encoding.UTF8, "application/json"));
var tutorial = await client.PostAsync("/tutorial/pack_info", new StringContent(json, Encoding.UTF8, "application/json"));
Assert.That(direct.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(tutorial.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var directBody = await direct.Content.ReadAsStringAsync();
var tutorialBody = await tutorial.Content.ReadAsStringAsync();
Assert.That(tutorialBody, Is.EqualTo(directBody),
"tutorial/pack_info wire shape must match /pack/info exactly (no filtering in v1).");
}
[Test]
public async Task TutorialPackOpen_grants_pack_and_sets_tutorial_step_100()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
// Seed the starter ticket the gift_receive step would have granted. /tutorial/pack_open
// is supposed to decrement this count by `pack_number` (1) and emit a post-state entry
// into reward_list (per project_wire_reward_list_post_state).
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
// Pack 99047 (starter legendary) has base_pack_id=90001. The minimal card seed only
// creates set 10001, so we seed set 90001 explicitly for the pool resolver.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardSets.Add(new ShadowverseCardSetEntry
{
Id = 90001,
Name = "TutorialStarterSet",
IsInRotation = true,
IsBasic = false,
Cards =
[
new ShadowverseCardEntry { Id = 90001001L, Name = "StarterCard1", Rarity = Rarity.Bronze },
new ShadowverseCardEntry { Id = 90001002L, Name = "StarterCard2", Rarity = Rarity.Gold },
new ShadowverseCardEntry { Id = 90001003L, Name = "StarterCard3", Rarity = Rarity.Legendary },
],
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, 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.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100),
"tutorial/pack_open must include tutorial_step=100 in data — this is the END transition.");
Assert.That(root.GetProperty("pack_list").GetArrayLength(), Is.EqualTo(8),
"Starter pack 99047/990047 delivers 8 cards (child_gacha.card_count=8).");
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100));
// Ticket decrement: the legendary starter ticket (90001) should be consumed.
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(0),
"Tutorial pack_open must decrement the gating ticket; otherwise /tutorial/pack_info " +
"keeps showing the pack and the client re-clicks into /pack/open (501 on type_detail=5).");
// reward_list must carry a post-state item entry for the ticket. RewardType=4 (Item),
// RewardId=90001, RewardNum=0 (post-state total, NOT delta).
var rewardList = root.GetProperty("reward_list");
var ticketEntry = rewardList.EnumerateArray()
.FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 4
&& e.GetProperty("reward_id").GetInt64() == 90001);
Assert.That(ticketEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
"reward_list must include a type=4 entry for the consumed ticket (90001) so the " +
"client's _userItemDict updates immediately — project_wire_reward_list_post_state.");
Assert.That(ticketEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(0),
"RewardNum is the post-state TOTAL, not the delta consumed.");
}
[Test]
public async Task NonTutorial_pack_open_does_not_emit_tutorial_step()
{
// Verify that regular /pack/open still works AND does not include tutorial_step in the response.
// Use the tutorial pack (99047/990047) which has type_detail=5 — the non-tutorial path
// still hits the currency_path_not_implemented guard and returns 501.
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/pack/open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
// Non-tutorial pack/open + type_detail=5 STILL returns 501 — that's the established behavior.
Assert.That((int)response.StatusCode, Is.EqualTo(501),
"Non-tutorial /pack/open with type_detail=5 should still hit the currency_path_not_implemented guard.");
// Even on a 501, no tutorial_step field should appear in the response body.
var body = await response.Content.ReadAsStringAsync();
Assert.That(body.Contains("\"tutorial_step\""), Is.False,
"Regular /pack/open must never emit tutorial_step.");
}
}