using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using RadicadeServer.DB; using RadicadeServer.RecNet.Config; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// The startup GameConfig passes share one rule: a row an earlier pass added to the SAME /// DbContext is not in the database yet, so a plain query for its key answers null - and the /// "make sure this key exists" step that follows then adds a SECOND instance of it. EF /// refuses that the moment the duplicate is tracked, and the refusal is a crash, not a /// warning. It is how the first start against an empty database died: /// /// Unhandled exception. System.InvalidOperationException: The instance of entity type /// 'ConfigVal' cannot be tracked because another instance with the same key value for /// {'Key'} is already being tracked. /// at Program.<<Main>$>g__EnsureSplitTestHardOverrides|0_32(RecDBContext dbContext) /// /// `EnsureRequiredGameConfigs` upserts `splitTestHardOverrides` and the ensure pass two lines /// later re-added it. GameConfigSeeder is the tracked-aware lookup that closes the gap; these /// cases hold it in place, and the same trap was armed behind it for /// `splitTestsAvailable`/`splitTestSegmentProbabilities` (RepairSplitTestConfigs) and the /// `UGC.Room2*` batch (EnableBetaCreativeToolsConfigs). /// public class GameConfigSeederTests { private const string HardOverridesKey = "splitTestHardOverrides"; /// Shared so two contexts can mean the same store, the way two passes of one start do. private static readonly InMemoryDatabaseRoot Root = new(); private static RecDBContext CreateDb(string name) => new( TestDbOptions.Create() .UseInMemoryDatabase(name, Root) .Options); private static RecDBContext CreateDb() => CreateDb(Guid.NewGuid().ToString()); [Fact] public async Task AddingTheSameKeyAfterAQuery_IsTheConflictTheSeederExistsToPrevent() { using var db = CreateDb(); await db.GameConfig.AddAsync(new ConfigVal(HardOverridesKey, "A=1")); // The trap: a row this context added but has not saved is invisible to a query. Assert.Null(await db.GameConfig.FirstOrDefaultAsync(x => x.Key == HardOverridesKey)); // And the duplicate a plain lookup would then add is fatal, not cosmetic. await Assert.ThrowsAsync(async () => await db.GameConfig.AddAsync(new ConfigVal(HardOverridesKey, "A=1;B=2"))); } [Fact] public async Task FindAsync_SeesARowThisContextAdded_WithoutSavingIt() { using var db = CreateDb(); await db.GameConfig.AddAsync(new ConfigVal(HardOverridesKey, "A=1")); var found = await GameConfigSeeder.FindAsync(db, HardOverridesKey); Assert.NotNull(found); Assert.Equal("A=1", found!.Value); Assert.Same(db.GameConfig.Local.Single(), found); } [Fact] public async Task UpsertAsync_TwiceInOnePass_TracksOneRowAndKeepsBothWrites() { // The literal shape of the crash: one pass upserts the key, the next looks it up and // (before the seeder) would have added a second ConfigVal carrying the same key. using var db = CreateDb(); await GameConfigSeeder.UpsertAsync(db, HardOverridesKey, "A=1"); var existing = await GameConfigSeeder.FindAsync(db, HardOverridesKey); Assert.NotNull(existing); existing!.Value = "A=1;B=2"; await db.SaveChangesAsync(); Assert.Single(db.ChangeTracker.Entries()); Assert.Equal("A=1;B=2", (await db.GameConfig.SingleAsync()).Value); } [Fact] public async Task FindAsync_ReadsARowAnotherContextStored() { var name = Guid.NewGuid().ToString(); using (var seeding = CreateDb(name)) { await GameConfigSeeder.UpsertAsync(seeding, HardOverridesKey, "A=1"); await seeding.SaveChangesAsync(); } using var db = CreateDb(name); Assert.Null(db.GameConfig.Local.FirstOrDefault()); // nothing tracked yet var found = await GameConfigSeeder.FindAsync(db, HardOverridesKey); Assert.NotNull(found); Assert.Equal("A=1", found!.Value); } [Fact] public async Task UpsertAsync_FillsABlankStoredValue_ButLeavesAnEditedOneAlone() { using var db = CreateDb(); await GameConfigSeeder.UpsertAsync(db, "splitTestSoftOverrides", "A=1"); await GameConfigSeeder.UpsertAsync(db, "splitTestSoftOverrides", "B=2"); Assert.Equal("A=1", (await GameConfigSeeder.FindAsync(db, "splitTestSoftOverrides"))!.Value); await GameConfigSeeder.UpsertAsync(db, "UGC.MaxChipsVisible", ""); await GameConfigSeeder.UpsertAsync(db, "UGC.MaxChipsVisible", "5000"); Assert.Equal("5000", (await GameConfigSeeder.FindAsync(db, "UGC.MaxChipsVisible"))!.Value); await GameConfigSeeder.UpsertAsync(db, "UGC.MaxChipsVisible", "9000"); Assert.Equal("5000", (await GameConfigSeeder.FindAsync(db, "UGC.MaxChipsVisible"))!.Value); } }