diff --git a/SVSim.Database/Models/SerialCodeEntry.cs b/SVSim.Database/Models/SerialCodeEntry.cs new file mode 100644 index 0000000..290e21a --- /dev/null +++ b/SVSim.Database/Models/SerialCodeEntry.cs @@ -0,0 +1,27 @@ +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// Top-level entity for a promotional serial code. Admin inserts these directly via SQL; +/// there is no JSON seed or admin endpoint. Case-sensitive match on . +/// +public class SerialCodeEntry : BaseEntity +{ + /// User-typed code. Case-sensitive; unique index enforces no duplicates. + public string Code { get; set; } = string.Empty; + + /// Player-facing mail body, copied onto every ViewerPresent created at redemption. + public string Message { get; set; } = string.Empty; + + /// When the code becomes valid. NULL = always valid from creation. + public DateTime? StartAt { get; set; } + + /// When the code expires. NULL = never expires. + public DateTime? EndAt { get; set; } + + /// Admin kill-switch. False = treat as if it doesn't exist. + public bool IsEnabled { get; set; } + + public List Rewards { get; set; } = new List(); +} diff --git a/SVSim.Database/Models/SerialCodeRewardEntry.cs b/SVSim.Database/Models/SerialCodeRewardEntry.cs new file mode 100644 index 0000000..76bb44d --- /dev/null +++ b/SVSim.Database/Models/SerialCodeRewardEntry.cs @@ -0,0 +1,24 @@ +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// One reward slot belonging to a . On redemption each row +/// becomes one in the player's gift inbox. +/// +public class SerialCodeRewardEntry : BaseEntity +{ + public int SerialCodeId { get; set; } + + /// 0-based ordering within the code's rewards. + public int Slot { get; set; } + + /// UserGoodsType cast to int (matches the wire convention used elsewhere). + public int RewardType { get; set; } + + /// Detail id for the goods. 0 for wallet currencies. + public long RewardDetailId { get; set; } + + /// Positive integer count. + public int RewardCount { get; set; } +} diff --git a/SVSim.Database/Models/ViewerSerialCodeRedemption.cs b/SVSim.Database/Models/ViewerSerialCodeRedemption.cs new file mode 100644 index 0000000..cc998f4 --- /dev/null +++ b/SVSim.Database/Models/ViewerSerialCodeRedemption.cs @@ -0,0 +1,13 @@ +namespace SVSim.Database.Models; + +/// +/// One row per (viewer, code) redemption. Composite PK on (ViewerId, SerialCodeId) +/// enforces the single-use-per-viewer guarantee at the DB layer; the controller catches +/// the unique-constraint violation as a race-condition backstop. +/// +public class ViewerSerialCodeRedemption +{ + public long ViewerId { get; set; } + public int SerialCodeId { get; set; } + public DateTime RedeemedAt { get; set; } +} diff --git a/SVSim.Database/SVSimDbContext.cs b/SVSim.Database/SVSimDbContext.cs index 5aa2bd7..4e18e29 100644 --- a/SVSim.Database/SVSimDbContext.cs +++ b/SVSim.Database/SVSimDbContext.cs @@ -108,6 +108,10 @@ public class SVSimDbContext : DbContext public DbSet ArenaTwoPickRewards { get; set; } = null!; public DbSet ViewerArenaTwoPickRuns { get; set; } = null!; + public DbSet SerialCodes => Set(); + public DbSet SerialCodeRewards => Set(); + public DbSet ViewerSerialCodeRedemptions => Set(); + #endregion public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) @@ -418,6 +422,39 @@ public class SVSimDbContext : DbContext b.HasIndex(e => new { e.ViewerId, e.AcquireTime, e.Id }); }); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.Property(e => e.Id).ValueGeneratedOnAdd(); + b.Property(e => e.Code).HasMaxLength(64).IsRequired(); + b.Property(e => e.Message).HasMaxLength(255); + b.HasIndex(e => e.Code).IsUnique(); + b.HasMany(e => e.Rewards) + .WithOne() + .HasForeignKey(r => r.SerialCodeId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.Property(e => e.Id).ValueGeneratedOnAdd(); + b.HasIndex(e => new { e.SerialCodeId, e.Slot }); + }); + + modelBuilder.Entity(b => + { + b.HasKey(e => new { e.ViewerId, e.SerialCodeId }); + b.HasOne() + .WithMany() + .HasForeignKey(e => e.ViewerId) + .OnDelete(DeleteBehavior.Cascade); + b.HasOne() + .WithMany() + .HasForeignKey(e => e.SerialCodeId) + .OnDelete(DeleteBehavior.Cascade); + }); + base.OnModelCreating(modelBuilder); } diff --git a/SVSim.UnitTests/Persistence/SerialCodePersistenceTests.cs b/SVSim.UnitTests/Persistence/SerialCodePersistenceTests.cs new file mode 100644 index 0000000..84b95e4 --- /dev/null +++ b/SVSim.UnitTests/Persistence/SerialCodePersistenceTests.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Persistence; + +public class SerialCodePersistenceTests +{ + [Test] + public async Task SerialCode_round_trips_with_rewards() + { + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var ctx = seedScope.ServiceProvider.GetRequiredService(); + ctx.SerialCodes.Add(new SerialCodeEntry + { + Code = "ABCD-1234", + Message = "Test code", + IsEnabled = true, + Rewards = + { + new SerialCodeRewardEntry { Slot = 0, RewardType = 1, RewardDetailId = 0, RewardCount = 100 }, + new SerialCodeRewardEntry { Slot = 1, RewardType = 9, RewardDetailId = 0, RewardCount = 500 }, + }, + }); + await ctx.SaveChangesAsync(); + } + + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + var code = await ctx2.SerialCodes + .Include(c => c.Rewards.OrderBy(r => r.Slot)) + .AsNoTracking() + .FirstAsync(c => c.Code == "ABCD-1234"); + + Assert.That(code.Message, Is.EqualTo("Test code")); + Assert.That(code.IsEnabled, Is.True); + Assert.That(code.Rewards, Has.Count.EqualTo(2)); + Assert.That(code.Rewards[0].RewardCount, Is.EqualTo(100)); + Assert.That(code.Rewards[1].RewardCount, Is.EqualTo(500)); + } + + [Test] + public async Task Unique_constraint_on_Code_rejects_duplicates() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + ctx.SerialCodes.Add(new SerialCodeEntry { Code = "DUP", Message = "first", IsEnabled = true }); + await ctx.SaveChangesAsync(); + + ctx.SerialCodes.Add(new SerialCodeEntry { Code = "DUP", Message = "second", IsEnabled = true }); + Assert.That(async () => await ctx.SaveChangesAsync(), Throws.Exception); + } + + [Test] + public async Task Composite_PK_on_redemption_rejects_double_redeem() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + int codeId; + using (var seedScope = factory.Services.CreateScope()) + { + var ctx = seedScope.ServiceProvider.GetRequiredService(); + var code = new SerialCodeEntry { Code = "ONCE", Message = "single use", IsEnabled = true }; + ctx.SerialCodes.Add(code); + await ctx.SaveChangesAsync(); + codeId = code.Id; + + ctx.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption + { + ViewerId = viewerId, SerialCodeId = codeId, RedeemedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + // Second redemption attempt in a fresh scope so the change tracker doesn't intercept + // before the DB constraint fires. + using var dupeScope = factory.Services.CreateScope(); + var ctx2 = dupeScope.ServiceProvider.GetRequiredService(); + ctx2.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption + { + ViewerId = viewerId, SerialCodeId = codeId, RedeemedAt = DateTime.UtcNow, + }); + Assert.That(async () => await ctx2.SaveChangesAsync(), Throws.Exception); + } +}