using Microsoft.Data.Sqlite; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.Tests; public sealed class SqliteMigratorTests : IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db"); private AuthSqliteConnectionFactory Factory => new(_dbPath); [Fact] public async Task MigrateAsync_CreatesAllThreeTables() { var migrator = new SqliteAuthStoreMigrator(Factory); await migrator.MigrateAsync(CancellationToken.None); Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable)); Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeyAuditTable)); Assert.True(await TableExistsAsync(SqliteAuthSchema.SchemaVersionTable)); } [Fact] public async Task MigrateAsync_RunTwice_IsIdempotentAndRecordsCurrentVersion() { var migrator = new SqliteAuthStoreMigrator(Factory); await migrator.MigrateAsync(CancellationToken.None); await migrator.MigrateAsync(CancellationToken.None); Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadVersionAsync()); Assert.Equal(1, await CountSchemaVersionRowsAsync()); } [Fact] public void CurrentVersion_Is2_ToMatchDonorGatewayDeployedSchema() => // The store was extracted from MxAccessGateway, whose deployed gateway-auth.db is // stamped version 2. The library must stamp 2 (not reset to 1) so it does not refuse // those existing databases on first boot. Locking this invariant. Assert.Equal(2, SqliteAuthSchema.CurrentVersion); [Fact] public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndStaysAt2() { // The deployed-gateway scenario: a database already provisioned at version 2. var migrator = new SqliteAuthStoreMigrator(Factory); await migrator.MigrateAsync(CancellationToken.None); await SetVersionAsync(2); await migrator.MigrateAsync(CancellationToken.None); // must not throw Assert.Equal(2, await ReadVersionAsync()); Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable)); } [Fact] public async Task MigrateAsync_FutureSchemaVersion_Throws() { var migrator = new SqliteAuthStoreMigrator(Factory); await migrator.MigrateAsync(CancellationToken.None); await SetVersionAsync(99); await Assert.ThrowsAsync( () => migrator.MigrateAsync(CancellationToken.None)); } private async Task TableExistsAsync(string tableName) { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name;"; command.Parameters.AddWithValue("$name", tableName); long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L); return count == 1; } private async Task ReadVersionAsync() { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT version FROM schema_version WHERE id = 1;"; object? value = await command.ExecuteScalarAsync(CancellationToken.None); return Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture); } private async Task CountSchemaVersionRowsAsync() { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT COUNT(*) FROM schema_version;"; long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L); return (int)count; } private async Task SetVersionAsync(int version) { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "UPDATE schema_version SET version = $version WHERE id = 1;"; command.Parameters.AddWithValue("$version", version); await command.ExecuteNonQueryAsync(CancellationToken.None); } public void Dispose() { SqliteConnection.ClearAllPools(); TryDelete(_dbPath); TryDelete(_dbPath + "-wal"); TryDelete(_dbPath + "-shm"); } private static void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { // Best-effort cleanup of the per-test temp database. } } }