Bisher gab es nur AppSettings.DefaultPermissionMode global — ein Task, der plan oder acceptEdits braucht, erzwang das Umstellen der globalen Einstellung. Neue Spalten tasks.permission_mode und list_config.permission_mode, Auflösung task -> list -> global im EffectiveRunConfigResolver (den TaskRunner und get_effective_run_config gemeinsam nutzen), ComboBox mit Inherited-Badge im geteilten Agent-Editor. MigrationBaselineTests: das Fixture baute per EnsureCreated das heutige Schema und stempelte Legacy-History darauf — jede Migration nach dem Squash lief damit in 'duplicate column name'. Es migriert jetzt gezielt bis InitialCreate und prueft 'nichts pending' statt 'InitialCreate ist die einzige Zeile'.
132 lines
5.3 KiB
C#
132 lines
5.3 KiB
C#
using ClaudeDo.Data.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore.Migrations;
|
|
|
|
namespace ClaudeDo.Data.Tests;
|
|
|
|
// The 40 incremental migrations were squashed into one InitialCreate, so every existing database
|
|
// carries a history EF no longer recognises. MigrateAndConfigure re-stamps those instead of
|
|
// re-creating tables — this is the only test covering that path (every other Data.Tests fixture
|
|
// uses EnsureCreated, which skips migrations entirely).
|
|
// Replaces TaskNumberMigrationTests, whose subject (the AddTaskNumbers backfill) was squashed away.
|
|
public sealed class MigrationBaselineTests : IDisposable
|
|
{
|
|
private const string LastPreSquashMigrationId = "20260825063230_AddPrimeActionKind";
|
|
private const string SquashedMigrationId = "20260826094154_InitialCreate";
|
|
|
|
private readonly string _dbPath;
|
|
private readonly DbContextOptions<ClaudeDoDbContext> _options;
|
|
|
|
public MigrationBaselineTests()
|
|
{
|
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_baseline_{Guid.NewGuid():N}.db");
|
|
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
|
try { File.Delete(_dbPath + suffix); } catch { }
|
|
}
|
|
|
|
[Fact]
|
|
public void Fresh_database_migrates_and_seeds()
|
|
{
|
|
using var ctx = new ClaudeDoDbContext(_options);
|
|
ClaudeDoDbContext.MigrateAndConfigure(ctx);
|
|
|
|
AssertFullyMigrated(ctx);
|
|
Assert.NotEmpty(ctx.Lists);
|
|
}
|
|
|
|
[Fact]
|
|
public void Fully_migrated_pre_squash_database_is_restamped_and_keeps_its_rows()
|
|
{
|
|
SeedSquashSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor");
|
|
|
|
using var ctx = new ClaudeDoDbContext(_options);
|
|
ClaudeDoDbContext.MigrateAndConfigure(ctx);
|
|
|
|
AssertFullyMigrated(ctx);
|
|
Assert.Equal("survivor", ctx.Tasks.Single().Title);
|
|
}
|
|
|
|
[Fact]
|
|
public void Restamping_is_idempotent()
|
|
{
|
|
SeedSquashSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor");
|
|
|
|
using (var first = new ClaudeDoDbContext(_options))
|
|
ClaudeDoDbContext.MigrateAndConfigure(first);
|
|
using var second = new ClaudeDoDbContext(_options);
|
|
ClaudeDoDbContext.MigrateAndConfigure(second);
|
|
|
|
AssertFullyMigrated(second);
|
|
Assert.Equal("survivor", second.Tasks.Single().Title);
|
|
}
|
|
|
|
[Fact]
|
|
public void Database_stuck_mid_chain_fails_loudly_instead_of_being_stamped()
|
|
{
|
|
SeedSquashSchemaWithHistory("20260810115437_AddFailureReason", taskTitle: "stale");
|
|
|
|
using var ctx = new ClaudeDoDbContext(_options);
|
|
var ex = Assert.Throws<InvalidOperationException>(() => ClaudeDoDbContext.MigrateAndConfigure(ctx));
|
|
Assert.Contains("Reinstall ClaudeDo", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pre_ef_database_without_history_is_baselined()
|
|
{
|
|
SeedSquashSchemaWithHistory(historyId: null, taskTitle: "legacy");
|
|
|
|
using var ctx = new ClaudeDoDbContext(_options);
|
|
ClaudeDoDbContext.MigrateAndConfigure(ctx);
|
|
|
|
AssertFullyMigrated(ctx);
|
|
Assert.Equal("legacy", ctx.Tasks.Single().Title);
|
|
}
|
|
|
|
// Builds the schema as of the squash point by migrating *to* InitialCreate, then rewrites the
|
|
// history into the legacy shape: current tables, old (or missing) history — what a database
|
|
// fully migrated by the old chain looks like.
|
|
// NOT EnsureCreated: that builds *today's* schema, so every migration added after the squash
|
|
// would then re-add a column that already exists ("duplicate column name").
|
|
private void SeedSquashSchemaWithHistory(string? historyId, string taskTitle)
|
|
{
|
|
using var seed = new ClaudeDoDbContext(_options);
|
|
seed.Database.GetService<IMigrator>().Migrate(SquashedMigrationId);
|
|
|
|
// Raw SQL, not EF: the current model knows columns this old schema doesn't have yet.
|
|
var conn = seed.Database.GetDbConnection();
|
|
conn.Open();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = $"""
|
|
INSERT INTO lists (id, name, created_at) VALUES ('l1', 'List', '2026-01-01 00:00:00');
|
|
INSERT INTO tasks (id, number, list_id, title, status, created_at)
|
|
VALUES ('t1', 1, 'l1', '{taskTitle}', 'idle', '2026-01-01 00:00:00');
|
|
{(historyId is null
|
|
? """DROP TABLE "__EFMigrationsHistory";"""
|
|
: $"""
|
|
DELETE FROM "__EFMigrationsHistory";
|
|
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
|
VALUES ('{historyId}', '8.0.11');
|
|
""")}
|
|
""";
|
|
cmd.ExecuteNonQuery();
|
|
conn.Close();
|
|
}
|
|
|
|
/// The restamp only sets the baseline — migrations added after the squash must still apply on
|
|
/// top, so assert "nothing pending", not "InitialCreate is the only row".
|
|
private static void AssertFullyMigrated(ClaudeDoDbContext ctx)
|
|
{
|
|
Assert.Contains(SquashedMigrationId, ctx.Database.GetAppliedMigrations());
|
|
Assert.Empty(ctx.Database.GetPendingMigrations());
|
|
}
|
|
}
|