Schema: ItemPurchaseCatalogEntry (single table). Per-viewer quota tracked via existing ViewerEventCounter keyed by "item_purchase:<id>" with period JstPeriod.MonthKey when IsMonthlyReset else AllTime. Controller: - /info returns catalog + per-period rest (server-computed max(0, PurchaseLimit - counter)) + user_card_pack_ticket_list (every Items.Type==2 row joined to viewer count, zeros included — client unconditionally UpdateItemNum's each entry). - /purchase: sold_out check before currency check (no counter increment on currency failure), inline TryDebit covers RedEther/Crystal/Rupy/Item with post-state-total reward_list entry, grant via RewardGrantService. Request `rest` accepted but ignored (server counter is canonical). Importer mirrors PaymentItemImporter shape — idempotent find-or-create, seed-missing rows preserved. 3 entries from the prod capture. 486 tests pass (was 476; +10 item_purchase tests). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
265 lines
12 KiB
C#
265 lines
12 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.Models;
|
|
using SVSim.UnitTests.Infrastructure;
|
|
|
|
namespace SVSim.UnitTests.Controllers;
|
|
|
|
public class ItemPurchaseControllerTests
|
|
{
|
|
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
|
|
|
/// <summary>
|
|
/// Seeds two catalog entries:
|
|
/// #501: lifetime quota 1, costs 100 RedEther → 1 Item(1000)
|
|
/// #502: monthly quota 3, costs 5 Item(1001) → 1 Item(1000)
|
|
/// Plus the Item rows (1000, 1001) needed by RewardGrantService.
|
|
/// Caller seeds the viewer with starting currency/items.
|
|
/// </summary>
|
|
private static async Task SeedCatalog(SVSimTestFactory f)
|
|
{
|
|
using var scope = f.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
|
|
if (!await db.Items.AnyAsync(i => i.Id == 1000))
|
|
db.Items.Add(new ItemEntry { Id = 1000, Name = "Seer's Globe", Type = 3, ThumbnailPath = "thumbnail_orb" });
|
|
if (!await db.Items.AnyAsync(i => i.Id == 1001))
|
|
db.Items.Add(new ItemEntry { Id = 1001, Name = "Seer's Globe Shards", Type = 5, ThumbnailPath = "thumbnail_orb_piece" });
|
|
|
|
db.ItemPurchaseCatalog.AddRange(
|
|
new ItemPurchaseCatalogEntry
|
|
{
|
|
Id = 501, IsEnabled = true,
|
|
RequireItemType = 1, RequireItemId = 0, RequireItemNum = 100, // 100 RedEther
|
|
PurchaseItemType = 4, PurchaseItemId = 1000, PurchaseItemNum = 1, // → 1 Globe
|
|
PurchaseName = "Lifetime Globe", IsMonthlyReset = false, PurchaseLimit = 1,
|
|
},
|
|
new ItemPurchaseCatalogEntry
|
|
{
|
|
Id = 502, IsEnabled = true,
|
|
RequireItemType = 4, RequireItemId = 1001, RequireItemNum = 5, // 5 Shards
|
|
PurchaseItemType = 4, PurchaseItemId = 1000, PurchaseItemNum = 1, // → 1 Globe
|
|
PurchaseName = "Monthly Globe", IsMonthlyReset = true, PurchaseLimit = 3,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static async Task SetViewerCurrency(SVSimTestFactory f, long viewerId, ulong redEther = 0)
|
|
{
|
|
using var scope = f.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
|
v.Currency.RedEther = redEther;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static async Task SetViewerItem(SVSimTestFactory f, long viewerId, int itemId, int count)
|
|
{
|
|
using var scope = f.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var item = await db.Items.FindAsync(itemId);
|
|
var v = await db.Viewers.Include(x => x.Items).ThenInclude(i => i.Item).FirstAsync(x => x.Id == viewerId);
|
|
var owned = v.Items.FirstOrDefault(i => i.Item.Id == itemId);
|
|
if (owned is null)
|
|
v.Items.Add(new OwnedItemEntry { Item = item!, Count = count, Viewer = v });
|
|
else
|
|
owned.Count = count;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Info_returns_catalog_and_full_ticket_list()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
long viewerId = await factory.SeedViewerAsync();
|
|
await factory.SeedGlobalsAsync(); // loads item catalog including Type==2 tickets
|
|
await SeedCatalog(factory);
|
|
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
var response = await client.PostAsync("/item_purchase/info",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
|
|
|
using var doc = JsonDocument.Parse(body);
|
|
var info = doc.RootElement.GetProperty("item_purchase_info");
|
|
Assert.That(info.GetArrayLength(), Is.GreaterThanOrEqualTo(2), "should include seeded entries 501 and 502");
|
|
|
|
var entry501 = FindEntry(info, 501);
|
|
Assert.That(entry501.GetProperty("require_item_num").GetInt32(), Is.EqualTo(100));
|
|
Assert.That(entry501.GetProperty("is_monthly_reset").GetInt32(), Is.EqualTo(0));
|
|
Assert.That(entry501.GetProperty("rest").GetInt32(), Is.EqualTo(1));
|
|
|
|
var entry502 = FindEntry(info, 502);
|
|
Assert.That(entry502.GetProperty("is_monthly_reset").GetInt32(), Is.EqualTo(1));
|
|
Assert.That(entry502.GetProperty("rest").GetInt32(), Is.EqualTo(3));
|
|
|
|
// Ticket list should include every Type==2 item — seeded items.json has ~33 such rows.
|
|
var tickets = doc.RootElement.GetProperty("user_card_pack_ticket_list");
|
|
Assert.That(tickets.GetArrayLength(), Is.GreaterThan(10), "all Type==2 items should be listed");
|
|
// First-element shape check
|
|
Assert.That(tickets[0].GetProperty("item_id").GetInt32(), Is.GreaterThan(0));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Purchase_with_red_ether_debits_and_grants_item()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
long viewerId = await factory.SeedViewerAsync();
|
|
await SeedCatalog(factory);
|
|
await SetViewerCurrency(factory, viewerId, redEther: 5000);
|
|
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
var response = await client.PostAsync("/item_purchase/purchase",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}"""));
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
|
|
|
using var doc = JsonDocument.Parse(body);
|
|
var rewardList = doc.RootElement.GetProperty("reward_list");
|
|
Assert.That(rewardList.GetArrayLength(), Is.EqualTo(2)); // debit post-state + grant
|
|
|
|
// Debit: RedEther type=1, id=0, post-state total 4900
|
|
var debit = rewardList[0];
|
|
Assert.That(debit.GetProperty("reward_type").GetInt32(), Is.EqualTo(1));
|
|
Assert.That(debit.GetProperty("reward_id").GetInt64(), Is.EqualTo(0));
|
|
Assert.That(debit.GetProperty("reward_num").GetInt32(), Is.EqualTo(4900));
|
|
|
|
// Grant: Item type=4, id=1000, count=1 (viewer didn't have any before)
|
|
var grant = rewardList[1];
|
|
Assert.That(grant.GetProperty("reward_type").GetInt32(), Is.EqualTo(4));
|
|
Assert.That(grant.GetProperty("reward_id").GetInt64(), Is.EqualTo(1000));
|
|
Assert.That(grant.GetProperty("reward_num").GetInt32(), Is.EqualTo(1));
|
|
|
|
// Counter row should exist for lifetime quota
|
|
using var scope = factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var counter = await db.ViewerEventCounters
|
|
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == "item_purchase:501");
|
|
Assert.That(counter, Is.Not.Null);
|
|
Assert.That(counter!.Period, Is.EqualTo("all-time"));
|
|
Assert.That(counter.Count, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Purchase_with_item_currency_debits_and_grants_item()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
long viewerId = await factory.SeedViewerAsync();
|
|
await SeedCatalog(factory);
|
|
await SetViewerItem(factory, viewerId, itemId: 1001, count: 12);
|
|
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
var response = await client.PostAsync("/item_purchase/purchase",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":502,"rest":3}"""));
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
|
|
|
using var doc = JsonDocument.Parse(body);
|
|
var rewardList = doc.RootElement.GetProperty("reward_list");
|
|
// Debit Item(1001) 12 → 7, grant Item(1000) 0 → 1
|
|
var debit = rewardList[0];
|
|
Assert.That(debit.GetProperty("reward_type").GetInt32(), Is.EqualTo(4));
|
|
Assert.That(debit.GetProperty("reward_id").GetInt64(), Is.EqualTo(1001));
|
|
Assert.That(debit.GetProperty("reward_num").GetInt32(), Is.EqualTo(7));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Purchase_sold_out_returns_400()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
long viewerId = await factory.SeedViewerAsync();
|
|
await SeedCatalog(factory);
|
|
await SetViewerCurrency(factory, viewerId, redEther: 500);
|
|
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
// First buy succeeds (entry 501 is lifetime quota 1)
|
|
var first = await client.PostAsync("/item_purchase/purchase",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}"""));
|
|
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
|
|
// Second buy rejected as sold_out — currency check is never reached
|
|
var second = await client.PostAsync("/item_purchase/purchase",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":0}"""));
|
|
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Purchase_with_insufficient_red_ether_returns_400_and_does_not_increment_counter()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
long viewerId = await factory.SeedViewerAsync();
|
|
await SeedCatalog(factory);
|
|
await SetViewerCurrency(factory, viewerId, redEther: 50); // < 100 required
|
|
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
var response = await client.PostAsync("/item_purchase/purchase",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}"""));
|
|
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
|
|
// Counter must NOT have been incremented — quota stays at 1.
|
|
using var scope = factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
|
var counter = await db.ViewerEventCounters
|
|
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == "item_purchase:501");
|
|
Assert.That(counter, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Purchase_unknown_purchase_id_returns_400()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
long viewerId = await factory.SeedViewerAsync();
|
|
// No SeedCatalog — purchase_id 501 doesn't exist
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
var response = await client.PostAsync("/item_purchase/purchase",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":501,"rest":1}"""));
|
|
|
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Monthly_quota_decrements_rest_on_repeat_buys_within_same_period()
|
|
{
|
|
using var factory = new SVSimTestFactory();
|
|
long viewerId = await factory.SeedViewerAsync();
|
|
await SeedCatalog(factory);
|
|
await SetViewerItem(factory, viewerId, itemId: 1001, count: 20);
|
|
|
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
// entry 502 is monthly quota 3; buy twice
|
|
for (int i = 0; i < 2; i++)
|
|
{
|
|
var resp = await client.PostAsync("/item_purchase/purchase",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","purchase_id":502,"rest":3}"""));
|
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
}
|
|
|
|
// /info should now report rest=1
|
|
var info = await client.PostAsync("/item_purchase/info",
|
|
JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
|
|
var body = await info.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(body);
|
|
var entry502 = FindEntry(doc.RootElement.GetProperty("item_purchase_info"), 502);
|
|
Assert.That(entry502.GetProperty("rest").GetInt32(), Is.EqualTo(1));
|
|
}
|
|
|
|
private static JsonElement FindEntry(JsonElement array, int purchaseId)
|
|
{
|
|
foreach (var entry in array.EnumerateArray())
|
|
{
|
|
if (entry.GetProperty("purchase_id").GetInt32() == purchaseId)
|
|
return entry;
|
|
}
|
|
throw new InvalidOperationException($"entry with purchase_id={purchaseId} not found");
|
|
}
|
|
}
|