diff --git a/SVSim.Database/Services/Inventory/InventoryTransaction.cs b/SVSim.Database/Services/Inventory/InventoryTransaction.cs index b357ea7..391d8d2 100644 --- a/SVSim.Database/Services/Inventory/InventoryTransaction.cs +++ b/SVSim.Database/Services/Inventory/InventoryTransaction.cs @@ -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 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 TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index ada7002..83b2fea 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -28,9 +28,11 @@ internal sealed class SVSimTestFactory : WebApplicationFactory { 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 db.Database.EnsureCreated(); db.EnsureSeedDataAsync().GetAwaiter().GetResult(); + if (_freeplayEnabled) + { + using var seedScope = host.Services.CreateScope(); + var seedDb = seedScope.ServiceProvider.GetRequiredService(); + 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. diff --git a/SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs b/SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs new file mode 100644 index 0000000..dc6c11d --- /dev/null +++ b/SVSim.UnitTests/Services/Inventory/InventorySpendTests.cs @@ -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(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 1000; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + 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(); + var v = await ctx.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = 100; + await ctx.SaveChangesAsync(); + + var inv = scope.ServiceProvider.GetRequiredService(); + 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(); + 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().Get(); + Assert.That(r.PostStateTotal, Is.EqualTo(checked((long)freeCfg.CurrencyAmount))); + Assert.That(tx.Viewer.Currency.Crystals, Is.EqualTo(balanceBefore), "freeplay never deducts"); + } +}