DB Cleanup
This commit is contained in:
2576
SVSim.Database/Migrations/20260525203838_AddOwnedEntryUniqueIndexes.Designer.cs
generated
Normal file
2576
SVSim.Database/Migrations/20260525203838_AddOwnedEntryUniqueIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOwnedEntryUniqueIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OwnedItemEntry_ViewerId_ItemId",
|
||||
table: "OwnedItemEntry",
|
||||
columns: new[] { "ViewerId", "ItemId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OwnedCardEntry_ViewerId_CardId",
|
||||
table: "OwnedCardEntry",
|
||||
columns: new[] { "ViewerId", "CardId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_OwnedItemEntry_ViewerId_ItemId",
|
||||
table: "OwnedItemEntry");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_OwnedCardEntry_ViewerId_CardId",
|
||||
table: "OwnedCardEntry");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2228,6 +2228,9 @@ namespace SVSim.Database.Migrations
|
||||
|
||||
b1.HasIndex("CardId");
|
||||
|
||||
b1.HasIndex("ViewerId", "CardId")
|
||||
.IsUnique();
|
||||
|
||||
b1.ToTable("OwnedCardEntry");
|
||||
|
||||
b1.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card")
|
||||
@@ -2263,6 +2266,9 @@ namespace SVSim.Database.Migrations
|
||||
|
||||
b1.HasIndex("ItemId");
|
||||
|
||||
b1.HasIndex("ViewerId", "ItemId")
|
||||
.IsUnique();
|
||||
|
||||
b1.ToTable("OwnedItemEntry");
|
||||
|
||||
b1.HasOne("SVSim.Database.Models.ItemEntry", "Item")
|
||||
|
||||
@@ -126,6 +126,21 @@ public class SVSimDbContext : DbContext
|
||||
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
||||
|
||||
// OwnedCardEntry and OwnedItemEntry use composite PK (ViewerId, Id) where Id is auto-
|
||||
// generated, which silently permits multiple rows per (Viewer, Card) or (Viewer, Item).
|
||||
// The intended semantic is one row per pair with Count as multiplicity — enforce that as
|
||||
// a unique index so any future find-or-add that forgets to .Include the collection (and
|
||||
// therefore re-creates a row that already exists in the DB) crashes loudly at SaveChanges
|
||||
// instead of silently duplicating ownership rows.
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.Cards, b =>
|
||||
{
|
||||
b.HasIndex("ViewerId", "CardId").IsUnique();
|
||||
});
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.Items, b =>
|
||||
{
|
||||
b.HasIndex("ViewerId", "ItemId").IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CardCosmeticReward>(b =>
|
||||
{
|
||||
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId });
|
||||
|
||||
@@ -248,4 +248,33 @@ public class RewardGrantServiceTests
|
||||
Assert.ThrowsAsync<NotSupportedException>(async () =>
|
||||
await svc.ApplyAsync(viewer, UserGoodsType.SpotCardOnlyLatestCardPack, 1L, 1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OwnedCardEntry_unique_index_blocks_duplicate_viewer_card_row()
|
||||
{
|
||||
// Schema-level safety net: any code that forgets to .Include(v => v.Cards) before doing
|
||||
// a find-or-add OwnedCardEntry would silently insert a duplicate row otherwise. The
|
||||
// unique index on (ViewerId, CardId) makes that crash loudly at SaveChanges instead.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
const long testCardId = 999_003_001L;
|
||||
var card = new ShadowverseCardEntry { Id = testCardId, Name = "UniqueIdxTest", Rarity = Rarity.Bronze };
|
||||
ctx.Cards.Add(card);
|
||||
var viewer = await ctx.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = 1, IsProtected = false });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
// Simulate the bug: a fresh viewer load WITHOUT .Include(v => v.Cards), then a manual
|
||||
// Add of a second row for the same (Viewer, Card). The unique index must reject this.
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var ctx2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var unloadedViewer = await ctx2.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
var sameCard = await ctx2.Cards.FirstAsync(c => c.Id == testCardId);
|
||||
unloadedViewer.Cards.Add(new OwnedCardEntry { Card = sameCard, Count = 1, IsProtected = false });
|
||||
|
||||
Assert.ThrowsAsync<DbUpdateException>(async () => await ctx2.SaveChangesAsync());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user