Files
Radicade/ServerTests/GameConfigSeederTests.cs
T
2026-10-16 23:33:13 -07:00

122 lines
5.1 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Config;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// 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.&lt;&lt;Main&gt;$&gt;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).
/// </summary>
public class GameConfigSeederTests
{
private const string HardOverridesKey = "splitTestHardOverrides";
/// <summary>Shared so two contexts can mean the same store, the way two passes of one start do.</summary>
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<InvalidOperationException>(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<ConfigVal>());
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);
}
}