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>
This commit is contained in:
38
SVSim.Bootstrap/Data/seeds/item-purchase.json
Normal file
38
SVSim.Bootstrap/Data/seeds/item-purchase.json
Normal file
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"purchase_id": 1,
|
||||
"require_item_type": 1,
|
||||
"require_item_id": 0,
|
||||
"require_item_num": 5000,
|
||||
"purchase_item_type": 4,
|
||||
"purchase_item_id": 1000,
|
||||
"purchase_item_num": 1,
|
||||
"purchase_name": "[b]One Time Only![/b] Seer's Globe x1",
|
||||
"is_monthly_reset": false,
|
||||
"purchase_limit": 1
|
||||
},
|
||||
{
|
||||
"purchase_id": 100002,
|
||||
"require_item_type": 4,
|
||||
"require_item_id": 1001,
|
||||
"require_item_num": 5,
|
||||
"purchase_item_type": 4,
|
||||
"purchase_item_id": 1000,
|
||||
"purchase_item_num": 1,
|
||||
"purchase_name": "",
|
||||
"is_monthly_reset": true,
|
||||
"purchase_limit": 3
|
||||
},
|
||||
{
|
||||
"purchase_id": 100003,
|
||||
"require_item_type": 1,
|
||||
"require_item_id": 0,
|
||||
"require_item_num": 30000,
|
||||
"purchase_item_type": 4,
|
||||
"purchase_item_id": 1000,
|
||||
"purchase_item_num": 1,
|
||||
"purchase_name": "",
|
||||
"is_monthly_reset": true,
|
||||
"purchase_limit": 10
|
||||
}
|
||||
]
|
||||
59
SVSim.Bootstrap/Importers/ItemPurchaseImporter.cs
Normal file
59
SVSim.Bootstrap/Importers/ItemPurchaseImporter.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of the item-purchase catalog from <c>seeds/item-purchase.json</c>.
|
||||
/// Source is the wire <c>/item_purchase/info</c> response, extracted via
|
||||
/// <c>data_dumps/extract/extract-item-purchase.py</c>. Rows missing from the seed are LEFT INTACT.
|
||||
/// </summary>
|
||||
public class ItemPurchaseImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
string path = Path.Combine(seedDir, "item-purchase.json");
|
||||
var seed = SeedLoader.LoadList<ItemPurchaseSeed>(path);
|
||||
if (seed.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[ItemPurchaseImporter] No seed rows; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var existing = await context.ItemPurchaseCatalog.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.PurchaseId == 0) continue;
|
||||
|
||||
var entry = existing.TryGetValue(s.PurchaseId, out var ex)
|
||||
? ex : new ItemPurchaseCatalogEntry { Id = s.PurchaseId };
|
||||
|
||||
entry.RequireItemType = s.RequireItemType;
|
||||
entry.RequireItemId = s.RequireItemId;
|
||||
entry.RequireItemNum = s.RequireItemNum;
|
||||
entry.PurchaseItemType = s.PurchaseItemType;
|
||||
entry.PurchaseItemId = s.PurchaseItemId;
|
||||
entry.PurchaseItemNum = s.PurchaseItemNum;
|
||||
entry.PurchaseName = s.PurchaseName;
|
||||
entry.IsMonthlyReset = s.IsMonthlyReset;
|
||||
entry.PurchaseLimit = s.PurchaseLimit;
|
||||
entry.IsEnabled = true;
|
||||
|
||||
if (ex is null)
|
||||
{
|
||||
context.ItemPurchaseCatalog.Add(entry);
|
||||
existing[s.PurchaseId] = entry;
|
||||
created++;
|
||||
}
|
||||
else updated++;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[ItemPurchaseImporter] +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
}
|
||||
17
SVSim.Bootstrap/Models/Seed/ItemPurchaseSeed.cs
Normal file
17
SVSim.Bootstrap/Models/Seed/ItemPurchaseSeed.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class ItemPurchaseSeed
|
||||
{
|
||||
[JsonPropertyName("purchase_id")] public int PurchaseId { get; set; }
|
||||
[JsonPropertyName("require_item_type")] public int RequireItemType { get; set; }
|
||||
[JsonPropertyName("require_item_id")] public long RequireItemId { get; set; }
|
||||
[JsonPropertyName("require_item_num")] public int RequireItemNum { get; set; }
|
||||
[JsonPropertyName("purchase_item_type")] public int PurchaseItemType { get; set; }
|
||||
[JsonPropertyName("purchase_item_id")] public long PurchaseItemId { get; set; }
|
||||
[JsonPropertyName("purchase_item_num")] public int PurchaseItemNum { get; set; }
|
||||
[JsonPropertyName("purchase_name")] public string PurchaseName { get; set; } = "";
|
||||
[JsonPropertyName("is_monthly_reset")] public bool IsMonthlyReset { get; set; }
|
||||
[JsonPropertyName("purchase_limit")] public int PurchaseLimit { get; set; }
|
||||
}
|
||||
@@ -99,6 +99,7 @@ public static class Program
|
||||
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new ItemImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new SleeveShopImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new ItemPurchaseImporter().ImportAsync(context, opts.SeedDir);
|
||||
var puzzleImporter = new PuzzleImporter();
|
||||
await puzzleImporter.ImportGroupsAsync(context, opts.SeedDir);
|
||||
await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir);
|
||||
|
||||
3430
SVSim.Database/Migrations/20260528021818_AddItemPurchaseCatalog.Designer.cs
generated
Normal file
3430
SVSim.Database/Migrations/20260528021818_AddItemPurchaseCatalog.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddItemPurchaseCatalog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ItemPurchaseCatalog",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
RequireItemType = table.Column<int>(type: "integer", nullable: false),
|
||||
RequireItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RequireItemNum = table.Column<int>(type: "integer", nullable: false),
|
||||
PurchaseItemType = table.Column<int>(type: "integer", nullable: false),
|
||||
PurchaseItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PurchaseItemNum = table.Column<int>(type: "integer", nullable: false),
|
||||
PurchaseName = table.Column<string>(type: "text", nullable: false),
|
||||
IsMonthlyReset = table.Column<bool>(type: "boolean", nullable: false),
|
||||
PurchaseLimit = table.Column<int>(type: "integer", nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ItemPurchaseCatalog", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ItemPurchaseCatalog");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1107,6 +1107,53 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("Items");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ItemPurchaseCatalogEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsMonthlyReset")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long>("PurchaseItemId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("PurchaseItemNum")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("PurchaseItemType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("PurchaseLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("PurchaseName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("RequireItemId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("RequireItemNum")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("RequireItemType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ItemPurchaseCatalog");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
39
SVSim.Database/Models/ItemPurchaseCatalogEntry.cs
Normal file
39
SVSim.Database/Models/ItemPurchaseCatalogEntry.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the /item_purchase/info catalog — an exchange the user can perform N times per
|
||||
/// period (monthly or lifetime) by spending <c>RequireItem*</c> to acquire <c>PurchaseItem*</c>.
|
||||
/// PK = wire <c>purchase_id</c>.
|
||||
/// <para>
|
||||
/// Both sides reference <see cref="Enums.UserGoodsType"/>. Captures show the common shape is
|
||||
/// currency-for-item (RedEther 5000 → Seer's Globe ×1) or item-for-item (Orb Shard ×5 →
|
||||
/// Seer's Globe ×1). Per-viewer remaining quota lives in
|
||||
/// <see cref="ViewerEventCounter"/> keyed by <c>"item_purchase:{Id}"</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ItemPurchaseCatalogEntry : BaseEntity<int>
|
||||
{
|
||||
public int RequireItemType { get; set; }
|
||||
public long RequireItemId { get; set; }
|
||||
public int RequireItemNum { get; set; }
|
||||
|
||||
public int PurchaseItemType { get; set; }
|
||||
public long PurchaseItemId { get; set; }
|
||||
public int PurchaseItemNum { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SystemText-ready display name. May be empty — the client falls back to a templated name
|
||||
/// built from <c>UserGoods.getUserGoodsName + count</c> via SystemText key "Shop_0132".
|
||||
/// </summary>
|
||||
public string PurchaseName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>True → quota resets at the start of each JST month. False → lifetime quota.</summary>
|
||||
public bool IsMonthlyReset { get; set; }
|
||||
|
||||
/// <summary>Per-period purchase cap. Wire <c>rest</c> = max(0, PurchaseLimit - counter).</summary>
|
||||
public int PurchaseLimit { get; set; }
|
||||
|
||||
public bool IsEnabled { get; set; }
|
||||
}
|
||||
@@ -72,6 +72,7 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
|
||||
public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>();
|
||||
public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>();
|
||||
public DbSet<ItemPurchaseCatalogEntry> ItemPurchaseCatalog => Set<ItemPurchaseCatalogEntry>();
|
||||
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||
|
||||
215
SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs
Normal file
215
SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /item_purchase/* — the generic item shop where viewers spend item-or-currency to acquire
|
||||
/// other items (e.g. RedEther → Seer's Globe, Orb Shards → Seer's Globe). Per-viewer monthly
|
||||
/// or lifetime quota tracked via <see cref="ViewerEventCounter"/>.
|
||||
/// </summary>
|
||||
[Route("item_purchase")]
|
||||
public class ItemPurchaseController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<ActionResult<ItemPurchaseInfoResponse>> Info()
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var catalog = await _db.ItemPurchaseCatalog
|
||||
.Where(c => c.IsEnabled)
|
||||
.OrderBy(c => c.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
var monthKey = JstPeriod.MonthKey(now);
|
||||
var keys = catalog.Select(c => CounterKey(c.Id)).ToList();
|
||||
var counters = await _db.ViewerEventCounters
|
||||
.Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey))
|
||||
.ToListAsync();
|
||||
|
||||
var info = new List<ItemPurchaseEntryDto>(catalog.Count);
|
||||
foreach (var c in catalog)
|
||||
{
|
||||
int count = CounterCount(counters, c, monthKey);
|
||||
info.Add(new ItemPurchaseEntryDto
|
||||
{
|
||||
PurchaseId = c.Id,
|
||||
RequireItemType = c.RequireItemType,
|
||||
RequireItemId = c.RequireItemId,
|
||||
RequireItemNum = c.RequireItemNum,
|
||||
PurchaseItemType = c.PurchaseItemType,
|
||||
PurchaseItemId = c.PurchaseItemId,
|
||||
PurchaseItemNum = c.PurchaseItemNum,
|
||||
PurchaseName = c.PurchaseName,
|
||||
IsMonthlyReset = c.IsMonthlyReset ? 1 : 0,
|
||||
Rest = Math.Max(0, c.PurchaseLimit - count),
|
||||
});
|
||||
}
|
||||
|
||||
// user_card_pack_ticket_list: every item with Type == 2 paired with the viewer's count
|
||||
// (zero counts included — the client unconditionally calls UpdateItemNum per entry).
|
||||
var ticketItems = await _db.Items
|
||||
.Where(i => i.Type == 2)
|
||||
.OrderByDescending(i => i.Id)
|
||||
.ToListAsync();
|
||||
var ownedByItemId = (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Items)
|
||||
.Select(oi => new { oi.Item.Id, oi.Count })
|
||||
.ToListAsync())
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
|
||||
var ticketList = ticketItems.Select(i => new UserCardPackTicketDto
|
||||
{
|
||||
ItemId = i.Id,
|
||||
Number = ownedByItemId.TryGetValue(i.Id, out var cnt) ? cnt : 0,
|
||||
}).ToList();
|
||||
|
||||
return new ItemPurchaseInfoResponse
|
||||
{
|
||||
ItemPurchaseInfo = info,
|
||||
UserCardPackTicketList = ticketList,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("purchase")]
|
||||
public async Task<ActionResult<ItemPurchasePurchaseResponse>> Purchase(ItemPurchasePurchaseRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var entry = await _db.ItemPurchaseCatalog.FindAsync(request.PurchaseId);
|
||||
if (entry is null || !entry.IsEnabled)
|
||||
return BadRequest(new { error = "unknown_purchase" });
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
var period = entry.IsMonthlyReset ? JstPeriod.MonthKey(now) : JstPeriod.AllTime;
|
||||
var key = CounterKey(entry.Id);
|
||||
|
||||
var counter = await _db.ViewerEventCounters
|
||||
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == key && c.Period == period);
|
||||
int currentCount = counter?.Count ?? 0;
|
||||
int rest = entry.PurchaseLimit - currentCount;
|
||||
if (rest <= 0)
|
||||
return BadRequest(new { error = "sold_out" });
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
|
||||
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
|
||||
var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
|
||||
// Grant the purchase side through the central dispatcher.
|
||||
var granted = await _rewards.ApplyAsync(viewer,
|
||||
(UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
|
||||
// Increment the per-period counter.
|
||||
if (counter is null)
|
||||
{
|
||||
_db.ViewerEventCounters.Add(new ViewerEventCounter
|
||||
{
|
||||
ViewerId = viewerId,
|
||||
EventKey = key,
|
||||
Period = period,
|
||||
Count = 1,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
counter.Count++;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ItemPurchasePurchaseResponse { RewardList = rewardList };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debit <paramref name="num"/> of (<paramref name="type"/>, <paramref name="detailId"/>)
|
||||
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
|
||||
/// uses to refresh its cached count. Returns an error string on insufficient balance.
|
||||
/// </summary>
|
||||
private static (RewardListEntry? PostState, string? Error) TryDebit(
|
||||
Viewer viewer, UserGoodsType type, long detailId, int num)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case UserGoodsType.RedEther:
|
||||
if (viewer.Currency.RedEther < (ulong)num)
|
||||
return (null, "insufficient_red_ether");
|
||||
viewer.Currency.RedEther -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null);
|
||||
|
||||
case UserGoodsType.Crystal:
|
||||
if (viewer.Currency.Crystals < (ulong)num)
|
||||
return (null, "insufficient_crystals");
|
||||
viewer.Currency.Crystals -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
|
||||
|
||||
case UserGoodsType.Rupy:
|
||||
if (viewer.Currency.Rupees < (ulong)num)
|
||||
return (null, "insufficient_rupees");
|
||||
viewer.Currency.Rupees -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
|
||||
|
||||
case UserGoodsType.Item:
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
if (owned is null || owned.Count < num)
|
||||
return (null, "insufficient_item");
|
||||
owned.Count -= num;
|
||||
return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null);
|
||||
|
||||
default:
|
||||
return (null, $"debit_type_not_supported:{type}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
|
||||
|
||||
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
|
||||
{
|
||||
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
|
||||
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
|
||||
}
|
||||
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
|
||||
|
||||
/// <summary>
|
||||
/// /item_purchase/purchase request body. <c>rest</c> is the client's locally-cached remaining
|
||||
/// quota — used as an optional optimistic-concurrency check on the server. Not authoritative;
|
||||
/// the server's own counter is canonical.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ItemPurchasePurchaseRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("purchase_id")]
|
||||
[Key("purchase_id")]
|
||||
public int PurchaseId { get; set; }
|
||||
|
||||
[JsonPropertyName("rest")]
|
||||
[Key("rest")]
|
||||
public int Rest { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
|
||||
|
||||
/// <summary>
|
||||
/// /item_purchase/info response.
|
||||
/// <para>
|
||||
/// <c>item_purchase_info</c> is an array of catalog entries with per-viewer <c>rest</c>
|
||||
/// (PurchaseLimit minus the viewer's counter for the relevant period).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>user_card_pack_ticket_list</c> is the FULL set of card-pack-ticket items (catalog
|
||||
/// Items.Type == 2) joined with the viewer's owned counts — even zero counts are emitted, as
|
||||
/// the client's parser unconditionally calls <c>PlayerStaticData.UpdateItemNum(item_id, number)</c>
|
||||
/// for every entry to refresh its in-memory mapping.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ItemPurchaseInfoResponse
|
||||
{
|
||||
[JsonPropertyName("item_purchase_info")]
|
||||
[Key("item_purchase_info")]
|
||||
public List<ItemPurchaseEntryDto> ItemPurchaseInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("user_card_pack_ticket_list")]
|
||||
[Key("user_card_pack_ticket_list")]
|
||||
public List<UserCardPackTicketDto> UserCardPackTicketList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class ItemPurchaseEntryDto
|
||||
{
|
||||
[JsonPropertyName("purchase_id")]
|
||||
[Key("purchase_id")]
|
||||
public int PurchaseId { get; set; }
|
||||
|
||||
[JsonPropertyName("require_item_type")]
|
||||
[Key("require_item_type")]
|
||||
public int RequireItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("require_item_id")]
|
||||
[Key("require_item_id")]
|
||||
public long RequireItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("require_item_num")]
|
||||
[Key("require_item_num")]
|
||||
public int RequireItemNum { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_name")]
|
||||
[Key("purchase_name")]
|
||||
public string PurchaseName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("purchase_item_type")]
|
||||
[Key("purchase_item_type")]
|
||||
public int PurchaseItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_item_id")]
|
||||
[Key("purchase_item_id")]
|
||||
public long PurchaseItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_item_num")]
|
||||
[Key("purchase_item_num")]
|
||||
public int PurchaseItemNum { get; set; }
|
||||
|
||||
/// <summary>0 or 1 — client compares to int 0.</summary>
|
||||
[JsonPropertyName("is_monthly_reset")]
|
||||
[Key("is_monthly_reset")]
|
||||
public int IsMonthlyReset { get; set; }
|
||||
|
||||
[JsonPropertyName("rest")]
|
||||
[Key("rest")]
|
||||
public int Rest { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class UserCardPackTicketDto
|
||||
{
|
||||
[JsonPropertyName("item_id")]
|
||||
[Key("item_id")]
|
||||
public int ItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("number")]
|
||||
[Key("number")]
|
||||
public int Number { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
|
||||
|
||||
/// <summary>
|
||||
/// /item_purchase/purchase response. <c>reward_list</c> uses the standard
|
||||
/// <see cref="RewardListEntry"/> shape: post-state totals for currencies, grant counts for
|
||||
/// items/cards. First entry is the debit-side post-state for the require_item; subsequent
|
||||
/// entries are the grant(s) from RewardGrantService.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ItemPurchasePurchaseResponse
|
||||
{
|
||||
[JsonPropertyName("reward_list")]
|
||||
[Key("reward_list")]
|
||||
public List<RewardListEntry> RewardList { get; set; } = new();
|
||||
}
|
||||
264
SVSim.UnitTests/Controllers/ItemPurchaseControllerTests.cs
Normal file
264
SVSim.UnitTests/Controllers/ItemPurchaseControllerTests.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
70
SVSim.UnitTests/Importers/ItemPurchaseImporterTests.cs
Normal file
70
SVSim.UnitTests/Importers/ItemPurchaseImporterTests.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Importers;
|
||||
|
||||
public class ItemPurchaseImporterTests
|
||||
{
|
||||
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||
|
||||
[Test]
|
||||
public async Task Imports_catalog_from_seed_file()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new ItemPurchaseImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
var entries = await db.ItemPurchaseCatalog.OrderBy(e => e.Id).ToListAsync();
|
||||
Assert.That(entries.Count, Is.GreaterThan(0));
|
||||
|
||||
// Spot-check purchase_id 1: One Time Only Seer's Globe — 5000 RedEther → 1 Item(1000),
|
||||
// lifetime quota of 1.
|
||||
var one = entries.First(e => e.Id == 1);
|
||||
Assert.That(one.RequireItemType, Is.EqualTo(1)); // RedEther
|
||||
Assert.That(one.RequireItemNum, Is.EqualTo(5000));
|
||||
Assert.That(one.PurchaseItemType, Is.EqualTo(4)); // Item
|
||||
Assert.That(one.PurchaseItemId, Is.EqualTo(1000)); // Seer's Globe
|
||||
Assert.That(one.IsMonthlyReset, Is.False);
|
||||
Assert.That(one.PurchaseLimit, Is.EqualTo(1));
|
||||
Assert.That(one.PurchaseName, Does.Contain("Seer's Globe"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Is_idempotent_on_rerun()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new ItemPurchaseImporter().ImportAsync(db, SeedDir);
|
||||
int before = await db.ItemPurchaseCatalog.CountAsync();
|
||||
await new ItemPurchaseImporter().ImportAsync(db, SeedDir);
|
||||
int after = await db.ItemPurchaseCatalog.CountAsync();
|
||||
|
||||
Assert.That(after, Is.EqualTo(before));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Leaves_existing_rows_untouched_when_missing_from_seed()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
const int legacyId = 999999;
|
||||
db.ItemPurchaseCatalog.Add(new ItemPurchaseCatalogEntry { Id = legacyId, PurchaseName = "legacy", IsEnabled = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await new ItemPurchaseImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
var legacy = await db.ItemPurchaseCatalog.FindAsync(legacyId);
|
||||
Assert.That(legacy, Is.Not.Null);
|
||||
Assert.That(legacy!.PurchaseName, Is.EqualTo("legacy"));
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
await new PaymentItemImporter().ImportAsync(ctx, seedDir);
|
||||
await new ItemImporter().ImportAsync(ctx, seedDir);
|
||||
await new SleeveShopImporter().ImportAsync(ctx, seedDir);
|
||||
await new ItemPurchaseImporter().ImportAsync(ctx, seedDir);
|
||||
var puzzleImporter = new PuzzleImporter();
|
||||
await puzzleImporter.ImportGroupsAsync(ctx, seedDir);
|
||||
await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir);
|
||||
|
||||
Reference in New Issue
Block a user