Files
SVSimServer/SVSim.UnitTests/Controllers/ItemPurchaseControllerTests.cs
gamer147 559a170957 feat(item-purchase): /item_purchase/{info,purchase} + catalog
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>
2026-05-27 22:41:02 -04:00

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");
}
}