feat(inventory): TrySpendAsync covers all 4 wallets + freeplay
Crystal/Rupy/RedEther freeplay no-op (returns configured amount, balance unchanged); SpotPoint always real. Insufficient returns current balance; success returns post-deduction balance. SVSimTestFactory gains freeplayEnabled ctor overload that upserts the Freeplay GameConfigSection row after EnsureSeedData. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -38,9 +38,48 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
_log = log;
|
||||
}
|
||||
|
||||
// Implementations land in later tasks. Throw NotImplementedException to keep the build green.
|
||||
public Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
{
|
||||
ThrowIfCommitted();
|
||||
if (cost < 0) cost = 0;
|
||||
|
||||
if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint)
|
||||
{
|
||||
long amount = checked((long)_freeplay.CurrencyAmount);
|
||||
_ops.Add(new SpendOp(currency, cost, amount));
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Success, amount));
|
||||
}
|
||||
|
||||
ulong current = ReadBalance(currency);
|
||||
if (current < (ulong)cost)
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
|
||||
|
||||
ulong post = current - (ulong)cost;
|
||||
WriteBalance(currency, post);
|
||||
_ops.Add(new SpendOp(currency, cost, (long)post));
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
|
||||
}
|
||||
|
||||
private ulong ReadBalance(SpendCurrency c) => c switch
|
||||
{
|
||||
SpendCurrency.Crystal => Viewer.Currency.Crystals,
|
||||
SpendCurrency.Rupee => Viewer.Currency.Rupees,
|
||||
SpendCurrency.RedEther => Viewer.Currency.RedEther,
|
||||
SpendCurrency.SpotPoint => Viewer.Currency.SpotPoints,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(c)),
|
||||
};
|
||||
|
||||
private void WriteBalance(SpendCurrency c, ulong value)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case SpendCurrency.Crystal: Viewer.Currency.Crystals = value; break;
|
||||
case SpendCurrency.Rupee: Viewer.Currency.Rupees = value; break;
|
||||
case SpendCurrency.RedEther: Viewer.Currency.RedEther = value; break;
|
||||
case SpendCurrency.SpotPoint: Viewer.Currency.SpotPoints = value; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(c));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SpendResult> TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
@@ -28,9 +28,11 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private long _nextSeededShortUdid = 400_000_001;
|
||||
private readonly bool _freeplayEnabled;
|
||||
|
||||
public SVSimTestFactory()
|
||||
public SVSimTestFactory(bool freeplayEnabled = false)
|
||||
{
|
||||
_freeplayEnabled = freeplayEnabled;
|
||||
// SQLite :memory: lives only as long as a connection is open — keep ours open for the
|
||||
// factory's lifetime so the DbContext can reattach to the same DB across scopes.
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
@@ -59,6 +61,19 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
db.Database.EnsureCreated();
|
||||
db.EnsureSeedDataAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (_freeplayEnabled)
|
||||
{
|
||||
using var seedScope = host.Services.CreateScope();
|
||||
var seedDb = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
const string freeplayJson = "{\"Enabled\":true,\"CurrencyAmount\":99999,\"CardCopies\":3}";
|
||||
var existing = seedDb.GameConfigs.FirstOrDefault(s => s.SectionName == "Freeplay");
|
||||
if (existing is null)
|
||||
seedDb.GameConfigs.Add(new SVSim.Database.Models.GameConfigSection { SectionName = "Freeplay", ValueJson = freeplayJson });
|
||||
else
|
||||
existing.ValueJson = freeplayJson;
|
||||
seedDb.SaveChanges();
|
||||
}
|
||||
|
||||
// Reference data is no longer HasData-seeded; load the CSVs via the same importer
|
||||
// production uses so tests exercise the same code path. CardCosmeticRewards skipped —
|
||||
// FK to Cards would reject every row against the minimal 3-card test seed below.
|
||||
|
||||
70
SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs
Normal file
70
SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services.Inventory;
|
||||
|
||||
public class InventorySpendTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Spend_sufficient_returns_post_deduction_total()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = 1000;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
await using var tx = await inv.BeginAsync(viewerId);
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 300);
|
||||
|
||||
Assert.That(r.Success, Is.True);
|
||||
Assert.That(r.PostStateTotal, Is.EqualTo(700));
|
||||
Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(700UL));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Spend_insufficient_returns_insufficient_with_current_balance()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = 100;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
await using var tx = await inv.BeginAsync(viewerId);
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 300);
|
||||
|
||||
Assert.That(r.Outcome, Is.EqualTo(SpendOutcome.Insufficient));
|
||||
Assert.That(r.PostStateTotal, Is.EqualTo(100));
|
||||
Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(100UL), "balance unchanged");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Freeplay_returns_success_with_configured_amount_for_main_currencies()
|
||||
{
|
||||
using var factory = new SVSimTestFactory(freeplayEnabled: true);
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
|
||||
var inv = scope.ServiceProvider.GetRequiredService<IInventoryService>();
|
||||
await using var tx = await inv.BeginAsync(viewerId);
|
||||
ulong balanceBefore = tx.Viewer.Currency.Crystals;
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, 99999);
|
||||
|
||||
Assert.That(r.Success, Is.True);
|
||||
var freeCfg = scope.ServiceProvider.GetRequiredService<IGameConfigService>().Get<FreeplayConfig>();
|
||||
Assert.That(r.PostStateTotal, Is.EqualTo(checked((long)freeCfg.CurrencyAmount)));
|
||||
Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(balanceBefore), "freeplay never deducts");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user