Extends /pack/open beyond the v1 CRYSTAL_MULTI=2 / DAILY=3 / RUPY_MULTI=7
trio to cover every type_detail whose payment primitive already exists:
1 CRYSTAL -> ICurrencySpendService crystal debit
6 RUPY -> ICurrencySpendService rupee debit
4 TICKET / 5 TICKET_MULTI -> debit child.ItemId from OwnedItemEntry
(ticketsNeeded = cost * packNumber), 400 on
missing/short balance; reward_list gets a
RewardType=4 post-state Item entry to mirror
project_wire_reward_list_post_state
Skin-overload type_details (8/9/13) and free-pack overlays (10/11/12)
stay 501 — they need selection / banner plumbing the current code
doesn't have.
Tutorial alias unchanged: it still consumes the gating ticket post-draw
and stamps tutorial_step=100. The two ticket flows diverge by intent
(tutorial = free server-grant; normal = paid by ticket inventory).
Removed Open_rejects_ticket_type_detail (asserted the old 501 path);
covered by Open_rejects_insufficient_tickets. Updated
NonTutorial_pack_open_does_not_emit_tutorial_step to assert the new
200-on-ticket-success behavior — same invariant under test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
250 lines
14 KiB
C#
250 lines
14 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 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]
|
|
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();
|
|
}
|
|
// Install a draw table for 99047 pointing at the seeded starter cards.
|
|
await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L);
|
|
|
|
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 works on a ticket-funded pack AND does not include
|
|
// tutorial_step in the response. Pack 99047 uses type_detail=5 (TICKET_MULTI), which
|
|
// the non-tutorial path now accepts: a normal viewer with a ticket buys a normal pack
|
|
// — only the /tutorial/pack_open alias attaches tutorial_step.
|
|
using var factory = new SVSimTestFactory();
|
|
await factory.SeedGlobalsAsync();
|
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
|
|
|
|
// Pack 99047 has BasePackId=90001 (Throwback). The minimal card seed only creates set
|
|
// 10001, so seed set 90001 explicitly + install a draw table pointing at its cards.
|
|
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();
|
|
}
|
|
await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L);
|
|
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
|
|
|
|
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"));
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
|
Assert.That(body.Contains("\"tutorial_step\""), Is.False,
|
|
"Regular /pack/open must never emit tutorial_step — only /tutorial/pack_open does.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task TutorialPackOpen_rejects_non_starter_parent_gacha_id()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
await factory.SeedGlobalsAsync();
|
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
|
|
// Pick any non-99047 parent_gacha_id seeded by SeedGlobalsAsync (10032 is the most
|
|
// recent crystal-multi pack in the catalog). The alias must reject it BadRequest.
|
|
var requestJson = """{"parent_gacha_id":10032,"gacha_id":100320,"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"));
|
|
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
|
"Tutorial alias must only accept the starter pack (99047); otherwise any authenticated " +
|
|
"viewer can draw any pack for free via the currency-bypass tutorial path.");
|
|
|
|
// State must NOT have advanced.
|
|
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41),
|
|
"Rejected requests leave TutorialState untouched.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task TutorialPackOpen_rejects_completed_viewer()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
await factory.SeedGlobalsAsync();
|
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
|
|
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
|
|
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"));
|
|
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
|
"Tutorial alias must reject viewers past the tutorial-end gate (state>=100); the path " +
|
|
"would otherwise re-clobber state and consume a ticket the viewer kept post-tutorial.");
|
|
|
|
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(1),
|
|
"Rejected requests do not consume tickets.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task TutorialPackOpen_does_not_downgrade_state_past_100()
|
|
{
|
|
// This is the max-preserve check. A future state > 100 (e.g., a post-tutorial training
|
|
// sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100,
|
|
// so synthesize the case directly.
|
|
using var factory = new SVSimTestFactory();
|
|
await factory.SeedGlobalsAsync();
|
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 200);
|
|
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"));
|
|
|
|
// Either the request is rejected (because state>=100, see Gate B above), OR — if the
|
|
// implementation reads the gate differently — at minimum the persisted state must not
|
|
// regress. Encode the load-bearing invariant: state never goes backwards.
|
|
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200),
|
|
"TutorialState must not regress regardless of the alias's accept/reject decision.");
|
|
}
|
|
}
|