Files
ChronoCCG/Chrono/DatabaseTest/Services/DatabaseService.cs
T
6d486f49 09a8adfc4a Test
2026-08-14 17:19:24 -04:00

973 lines
42 KiB
C#

using System.Diagnostics;
using System.Text.Json;
using Cloud;
using Cloud.Models;
using DatabaseTest.Models;
using Microsoft.EntityFrameworkCore;
using Model;
namespace DatabaseTest.Services;
public interface IDatabaseService
{
Task<DatabaseHealthStatus> CheckHealthAsync();
Task<MigrationReport> GetMigrationStatusAsync();
Task<OperationResult> ApplyMigrationsAsync();
Task<OperationResult> EnsureDatabaseCreatedAsync();
Task<OperationResult> ResetDatabaseAsync();
Task<SeedResult> SeedSampleDataAsync();
// Card Notes
Task<List<CardNote>> GetCardNotesAsync(string? search = null);
Task<CardNote?> GetCardNoteAsync(string cardName);
Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew);
Task<OperationResult> DeleteCardNoteAsync(string cardName);
// User Decks
Task<List<UserDeck>> GetUserDecksAsync(string? search = null);
Task<UserDeck?> GetUserDeckAsync(Guid id);
Task<OperationResult> SaveUserDeckAsync(UserDeck deck, bool isNew);
Task<OperationResult> DeleteUserDeckAsync(Guid id);
// Passkey Credentials
Task<List<PasskeyCredential>> GetPasskeysAsync(string? search = null);
Task<PasskeyCredential?> GetPasskeyAsync(int id);
Task<OperationResult> SavePasskeyAsync(PasskeyCredential passkey, bool isNew);
Task<OperationResult> DeletePasskeyAsync(int id);
// Automated Test Suite
Task<List<DatabaseTestCaseResult>> RunAllTestsAsync(Action<string>? progressCallback = null);
}
public class DatabaseService : IDatabaseService
{
private readonly IDatabaseConfigService _configService;
private readonly ILogger<DatabaseService> _logger;
public DatabaseService(IDatabaseConfigService configService, ILogger<DatabaseService> logger)
{
_configService = configService;
_logger = logger;
}
public async Task<DatabaseHealthStatus> CheckHealthAsync()
{
var status = new DatabaseHealthStatus
{
ProviderType = _configService.ProviderType,
ProviderName = _configService.ProviderType.ToString(),
ConnectionString = _configService.ConnectionString,
CheckedAt = DateTime.UtcNow
};
var sw = Stopwatch.StartNew();
try
{
await using var db = _configService.CreateDbContext();
var canConnect = await db.Database.CanConnectAsync();
sw.Stop();
status.ResponseTimeMs = sw.ElapsedMilliseconds;
status.IsConnected = canConnect;
if (canConnect)
{
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
var pending = await db.Database.GetPendingMigrationsAsync();
var applied = await db.Database.GetAppliedMigrationsAsync();
status.PendingMigrationsCount = pending.Count();
status.AppliedMigrationsCount = applied.Count();
}
try
{
status.CardNotesCount = await db.CardNotes.CountAsync();
status.UserDecksCount = await db.UserDecks.CountAsync();
status.PasskeysCount = await db.PasskeyCredentials.CountAsync();
}
catch (Exception ex)
{
status.ErrorMessage = $"Connected, but table count failed (migrations may be pending): {ex.Message}";
}
}
else
{
status.ErrorMessage = "Unable to connect to database. Verify connection string and server status.";
}
}
catch (Exception ex)
{
sw.Stop();
status.ResponseTimeMs = sw.ElapsedMilliseconds;
status.IsConnected = false;
status.ErrorMessage = ex.Message;
}
return status;
}
public async Task<MigrationReport> GetMigrationStatusAsync()
{
var report = new MigrationReport
{
ProviderType = _configService.ProviderType
};
if (_configService.ProviderType != DatabaseProviderType.PostgreSQL)
{
report.SupportsMigrations = false;
return report;
}
report.SupportsMigrations = true;
try
{
await using var db = _configService.CreateDbContext();
report.AppliedMigrations = (await db.Database.GetAppliedMigrationsAsync()).ToList();
report.PendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToList();
}
catch (Exception ex)
{
report.Error = ex.Message;
}
return report;
}
public async Task<OperationResult> ApplyMigrationsAsync()
{
try
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
var pending = (await db.Database.GetPendingMigrationsAsync()).ToList();
if (!pending.Any())
{
return OperationResult.Ok("Database schema is already up-to-date. No pending migrations.");
}
await db.Database.MigrateAsync();
return OperationResult.Ok($"Successfully applied {pending.Count} migration(s): {string.Join(", ", pending)}");
}
else
{
await db.Database.EnsureCreatedAsync();
return OperationResult.Ok("In-memory database schema ensured.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply migrations.");
return OperationResult.Fail("Failed to apply migrations: " + ex.Message, ex.ToString());
}
}
public async Task<OperationResult> EnsureDatabaseCreatedAsync()
{
try
{
await using var db = _configService.CreateDbContext();
var created = await db.Database.EnsureCreatedAsync();
return OperationResult.Ok(created ? "Database created successfully." : "Database already exists.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to ensure database creation.");
return OperationResult.Fail("Failed to ensure database created: " + ex.Message, ex.ToString());
}
}
public async Task<OperationResult> ResetDatabaseAsync()
{
try
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
// Delete rows from all tables
db.CardNotes.RemoveRange(db.CardNotes);
db.UserDecks.RemoveRange(db.UserDecks);
db.PasskeyCredentials.RemoveRange(db.PasskeyCredentials);
await db.SaveChangesAsync();
return OperationResult.Ok("All database tables cleared successfully.");
}
else
{
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
return OperationResult.Ok("In-memory database wiped and recreated successfully.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reset database.");
return OperationResult.Fail("Failed to reset database: " + ex.Message, ex.ToString());
}
}
public async Task<SeedResult> SeedSampleDataAsync()
{
var result = new SeedResult();
try
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
await db.Database.MigrateAsync();
}
else
{
await db.Database.EnsureCreatedAsync();
}
// 1. Seed Card Notes
var existingNoteNames = (await db.CardNotes.Select(n => n.CardName).ToListAsync()).ToHashSet();
var sampleNotes = new List<CardNote>
{
new() { CardName = "Kaelen, Arcane Weaver", Note = "Key combo enabler for control decks. High priority removal target." },
new() { CardName = "Valkyrie Ascendant", Note = "Strong mid-game aerial presence. Synergizes well with Divine Shield buffs." },
new() { CardName = "Chronos, Time Shifter", Note = "Turn rewind capability allows recovering from early tempo loss." },
new() { CardName = "Cyber Samurai", Note = "Fast aggro card with stealth. Great opener." },
new() { CardName = "Temporal Rift", Note = "Essential spell for board reset in late game." }
};
foreach (var note in sampleNotes.Where(n => !existingNoteNames.Contains(n.CardName)))
{
db.CardNotes.Add(note);
result.CardNotesSeeded++;
}
// 2. Seed User Decks
var existingDeckNames = (await db.UserDecks.Select(d => d.Name).ToListAsync()).ToHashSet();
var sampleDecks = new List<UserDeck>
{
new()
{
Id = Guid.NewGuid(),
Name = "Chrono Temporal Control",
Notes = "Built around time-shift mechanics and spell stalling.",
Season = "Season 1",
UpdatedAt = DateTime.UtcNow,
Cards = new List<string>
{
"Chronos, Time Shifter",
"Chronos, Time Shifter",
"Temporal Rift",
"Temporal Rift",
"Temporal Rift",
"Kaelen, Arcane Weaver",
"Kaelen, Arcane Weaver"
},
Divers = new List<string> { "Chronos, Time Shifter" }
},
new()
{
Id = Guid.NewGuid(),
Name = "Valkyrie Aggro Burst",
Notes = "Fast tempo rush deck targeting rapid face damage.",
Season = "Season 1",
UpdatedAt = DateTime.UtcNow,
Cards = new List<string>
{
"Valkyrie Ascendant",
"Valkyrie Ascendant",
"Valkyrie Ascendant",
"Cyber Samurai",
"Cyber Samurai",
"Cyber Samurai"
},
Divers = new List<string>()
}
};
foreach (var deck in sampleDecks.Where(d => !existingDeckNames.Contains(d.Name)))
{
db.UserDecks.Add(deck);
result.UserDecksSeeded++;
}
// 3. Seed Passkey Credentials
var existingPasskeyCount = await db.PasskeyCredentials.CountAsync();
if (existingPasskeyCount == 0)
{
var samplePasskeys = new List<PasskeyCredential>
{
new()
{
CredentialId = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0x01, 0x02, 0x03, 0x04, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80 },
PublicKey = new byte[] { 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07 },
UserHandle = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 },
AaGuid = Guid.NewGuid().ToString(),
SignCount = 42
},
new()
{
CredentialId = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 },
PublicKey = new byte[] { 0x04, 0x1F, 0x2E, 0x3D, 0x4C, 0x5B, 0x6A, 0x79, 0x88, 0x97, 0xA6, 0xB5, 0xC4, 0xD3, 0xE2, 0xF1 },
UserHandle = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE, 0x05, 0x06, 0x07, 0x08 },
AaGuid = Guid.NewGuid().ToString(),
SignCount = 100
}
};
db.PasskeyCredentials.AddRange(samplePasskeys);
result.PasskeysSeeded += samplePasskeys.Count;
}
await db.SaveChangesAsync();
result.Success = true;
result.Message = $"Sample data seeded: {result.CardNotesSeeded} notes, {result.UserDecksSeeded} decks, {result.PasskeysSeeded} passkeys.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to seed sample data.");
result.Success = false;
result.Message = "Failed to seed data: " + ex.Message;
}
return result;
}
// ── Card Notes CRUD ────────────────────────────────────────────────────────
public async Task<List<CardNote>> GetCardNotesAsync(string? search = null)
{
await using var db = _configService.CreateDbContext();
var query = db.CardNotes.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(n => n.CardName.ToLower().Contains(s) || n.Note.ToLower().Contains(s));
}
return await query.OrderBy(n => n.CardName).ToListAsync();
}
public async Task<CardNote?> GetCardNoteAsync(string cardName)
{
await using var db = _configService.CreateDbContext();
return await db.CardNotes.AsNoTracking().FirstOrDefaultAsync(n => n.CardName == cardName);
}
public async Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew)
{
if (string.IsNullOrWhiteSpace(note.CardName))
return OperationResult.Fail("Card name is required.");
try
{
await using var db = _configService.CreateDbContext();
if (isNew)
{
var exists = await db.CardNotes.AnyAsync(n => n.CardName == note.CardName);
if (exists)
return OperationResult.Fail($"A note for card '{note.CardName}' already exists.");
db.CardNotes.Add(note);
await db.SaveChangesAsync();
return OperationResult.Ok($"Created note for '{note.CardName}'.");
}
else
{
var existing = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == note.CardName);
if (existing == null)
return OperationResult.Fail($"Note for card '{note.CardName}' was not found.");
existing.Note = note.Note;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated note for '{note.CardName}'.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save card note {CardName}", note.CardName);
return OperationResult.Fail($"Error saving card note: {ex.Message}", ex.ToString());
}
}
public async Task<OperationResult> DeleteCardNoteAsync(string cardName)
{
try
{
await using var db = _configService.CreateDbContext();
var existing = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == cardName);
if (existing == null)
return OperationResult.Fail($"Note for '{cardName}' not found.");
db.CardNotes.Remove(existing);
await db.SaveChangesAsync();
return OperationResult.Ok($"Deleted note for '{cardName}'.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete card note {CardName}", cardName);
return OperationResult.Fail($"Error deleting note: {ex.Message}", ex.ToString());
}
}
// ── User Decks CRUD ────────────────────────────────────────────────────────
public async Task<List<UserDeck>> GetUserDecksAsync(string? search = null)
{
await using var db = _configService.CreateDbContext();
var query = db.UserDecks.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(d => d.Name.ToLower().Contains(s)
|| (d.Notes != null && d.Notes.ToLower().Contains(s))
|| (d.Season != null && d.Season.ToLower().Contains(s)));
}
return await query.OrderByDescending(d => d.UpdatedAt).ToListAsync();
}
public async Task<UserDeck?> GetUserDeckAsync(Guid id)
{
await using var db = _configService.CreateDbContext();
return await db.UserDecks.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id);
}
public async Task<OperationResult> SaveUserDeckAsync(UserDeck deck, bool isNew)
{
if (string.IsNullOrWhiteSpace(deck.Name))
return OperationResult.Fail("Deck name is required.");
try
{
await using var db = _configService.CreateDbContext();
deck.UpdatedAt = DateTime.UtcNow;
if (isNew)
{
if (deck.Id == Guid.Empty)
deck.Id = Guid.NewGuid();
db.UserDecks.Add(deck);
await db.SaveChangesAsync();
return OperationResult.Ok($"Created deck '{deck.Name}'.");
}
else
{
var existing = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == deck.Id);
if (existing == null)
return OperationResult.Fail($"Deck with ID {deck.Id} was not found.");
existing.Name = deck.Name;
existing.Notes = deck.Notes;
existing.Season = deck.Season;
existing.Cards = deck.Cards ?? [];
existing.Divers = deck.Divers ?? [];
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated deck '{deck.Name}'.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save deck {DeckName}", deck.Name);
return OperationResult.Fail($"Error saving deck: {ex.Message}", ex.ToString());
}
}
public async Task<OperationResult> DeleteUserDeckAsync(Guid id)
{
try
{
await using var db = _configService.CreateDbContext();
var existing = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == id);
if (existing == null)
return OperationResult.Fail($"Deck with ID {id} was not found.");
var name = existing.Name;
db.UserDecks.Remove(existing);
await db.SaveChangesAsync();
return OperationResult.Ok($"Deleted deck '{name}'.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete deck {DeckId}", id);
return OperationResult.Fail($"Error deleting deck: {ex.Message}", ex.ToString());
}
}
// ── Passkey Credentials CRUD ───────────────────────────────────────────────
public async Task<List<PasskeyCredential>> GetPasskeysAsync(string? search = null)
{
await using var db = _configService.CreateDbContext();
var query = db.PasskeyCredentials.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(p => (p.AaGuid != null && p.AaGuid.ToLower().Contains(s))
|| p.Id.ToString().Contains(s)
|| p.SignCount.ToString().Contains(s));
}
return await query.OrderBy(p => p.Id).ToListAsync();
}
public async Task<PasskeyCredential?> GetPasskeyAsync(int id)
{
await using var db = _configService.CreateDbContext();
return await db.PasskeyCredentials.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<OperationResult> SavePasskeyAsync(PasskeyCredential passkey, bool isNew)
{
if (passkey.CredentialId == null || passkey.CredentialId.Length == 0)
return OperationResult.Fail("Credential ID bytes are required.");
if (passkey.PublicKey == null || passkey.PublicKey.Length == 0)
return OperationResult.Fail("Public Key bytes are required.");
if (passkey.UserHandle == null || passkey.UserHandle.Length == 0)
return OperationResult.Fail("User Handle bytes are required.");
try
{
await using var db = _configService.CreateDbContext();
if (isNew)
{
db.PasskeyCredentials.Add(passkey);
await db.SaveChangesAsync();
return OperationResult.Ok($"Created Passkey credential #{passkey.Id}.");
}
else
{
var existing = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == passkey.Id);
if (existing == null)
return OperationResult.Fail($"Passkey with ID {passkey.Id} was not found.");
existing.CredentialId = passkey.CredentialId;
existing.PublicKey = passkey.PublicKey;
existing.UserHandle = passkey.UserHandle;
existing.AaGuid = passkey.AaGuid;
existing.SignCount = passkey.SignCount;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated Passkey credential #{passkey.Id}.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save passkey credential {Id}", passkey.Id);
return OperationResult.Fail($"Error saving passkey: {ex.Message}", ex.ToString());
}
}
public async Task<OperationResult> DeletePasskeyAsync(int id)
{
try
{
await using var db = _configService.CreateDbContext();
var existing = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == id);
if (existing == null)
return OperationResult.Fail($"Passkey with ID {id} was not found.");
db.PasskeyCredentials.Remove(existing);
await db.SaveChangesAsync();
return OperationResult.Ok($"Deleted Passkey credential #{id}.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete passkey credential {Id}", id);
return OperationResult.Fail($"Error deleting passkey: {ex.Message}", ex.ToString());
}
}
// ── Automated Test Suite Runner ───────────────────────────────────────────
public async Task<List<DatabaseTestCaseResult>> RunAllTestsAsync(Action<string>? progressCallback = null)
{
var results = new List<DatabaseTestCaseResult>();
// 1. Connection Test
results.Add(await RunTestCaseAsync("DB-01", "Database Connectivity", "Connection",
"Validates socket/connection pool access to the target database server.",
async test =>
{
test.AddLog($"Connecting to {_configService.GetActiveProviderDescription()}...");
await using var db = _configService.CreateDbContext();
var canConnect = await db.Database.CanConnectAsync();
if (!canConnect)
throw new InvalidOperationException("Failed to establish a connection to the database.");
test.AddLog("Database connection established successfully.");
}, progressCallback));
// 2. Migrations & Schema Check
results.Add(await RunTestCaseAsync("DB-02", "Schema & Migrations", "Schema",
"Verifies applied EF Core migrations and checks that all tables exist.",
async test =>
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
test.AddLog("Querying EF Core migration history...");
var applied = (await db.Database.GetAppliedMigrationsAsync()).ToList();
test.AddLog($"Applied migrations count: {applied.Count}");
foreach (var m in applied) test.AddLog($" - Applied: {m}");
var pending = (await db.Database.GetPendingMigrationsAsync()).ToList();
if (pending.Any())
{
test.AddLog($"Warning: {pending.Count} pending migrations detected. Applying them now...");
await db.Database.MigrateAsync();
test.AddLog("Pending migrations applied successfully.");
}
else
{
test.AddLog("No pending migrations. Schema is up to date.");
}
}
else
{
test.AddLog("In-memory provider: Ensuring database schema is created...");
await db.Database.EnsureCreatedAsync();
test.AddLog("In-memory schema verified.");
}
}, progressCallback));
// 3. CardNote CRUD Test
results.Add(await RunTestCaseAsync("DB-03", "CardNote Full CRUD Lifecycle", "CardNotes",
"Tests Insert, Query, Update, and Delete operations on the CardNotes entity.",
async test =>
{
var testCardName = $"__Test_Card_{Guid.NewGuid():N}";
var initialNote = "Initial test note description";
var updatedNote = "Updated test note content with extra details";
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Creating test CardNote for '{testCardName}'...");
db.CardNotes.Add(new CardNote { CardName = testCardName, Note = initialNote });
await db.SaveChangesAsync();
test.AddLog("CardNote inserted successfully.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Querying inserted CardNote from database...");
var fetched = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == testCardName);
if (fetched == null || fetched.Note != initialNote)
throw new Exception($"Expected note '{initialNote}', but retrieved '{fetched?.Note}'");
test.AddLog("CardNote retrieved and validated.");
test.AddLog("3. Updating CardNote text...");
fetched.Note = updatedNote;
await db.SaveChangesAsync();
test.AddLog("CardNote updated in database.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Verifying persisted updated note...");
var fetched = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == testCardName);
if (fetched == null || fetched.Note != updatedNote)
throw new Exception($"Expected updated note '{updatedNote}', but found '{fetched?.Note}'");
test.AddLog("Update persistence verified.");
test.AddLog("5. Deleting test CardNote...");
db.CardNotes.Remove(fetched);
await db.SaveChangesAsync();
test.AddLog("CardNote deleted.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("6. Verifying CardNote is deleted...");
var exists = await db.CardNotes.AnyAsync(n => n.CardName == testCardName);
if (exists)
throw new Exception("CardNote still exists after deletion.");
test.AddLog("CardNote deletion verified.");
}
}, progressCallback));
// 4. UserDeck CRUD & JSONB Serialization Test
results.Add(await RunTestCaseAsync("DB-04", "UserDeck CRUD & JSONB Mapping", "UserDecks",
"Validates JSON column mapping for Cards and Divers collections in PostgreSQL/EF Core.",
async test =>
{
var testDeckId = Guid.NewGuid();
var testDeckName = $"Test Deck {Guid.NewGuid():N}";
var initialCards = new List<string>
{
"Agent Alpha",
"Agent Alpha",
"Agent Alpha",
"Spell Omega",
"Spell Omega"
};
var initialDivers = new List<string> { "Agent Alpha" };
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Inserting UserDeck '{testDeckName}' with {initialCards.Count} cards and {initialDivers.Count} divers...");
var deck = new UserDeck
{
Id = testDeckId,
Name = testDeckName,
Notes = "Automated test deck notes",
Season = "Test Season 1",
UpdatedAt = DateTime.UtcNow,
Cards = initialCards,
Divers = initialDivers
};
db.UserDecks.Add(deck);
await db.SaveChangesAsync();
test.AddLog("UserDeck saved with JSON structures.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Querying UserDeck and checking deserialized JSON lists...");
var fetched = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == testDeckId);
if (fetched == null) throw new Exception("UserDeck not found after insertion.");
if (fetched.Cards == null || fetched.Cards.Count != 5)
throw new Exception($"Expected 5 cards in deck, found {fetched.Cards?.Count ?? 0}");
if (fetched.Divers == null || fetched.Divers.Count != 1 || fetched.Divers[0] != "Agent Alpha")
throw new Exception($"Diver entry deserialization mismatch.");
test.AddLog("JSON properties successfully verified.");
test.AddLog("3. Modifying cards list in UserDeck...");
fetched.Cards.Add("Added Card Gamma");
fetched.Divers.Add("Added Diver");
fetched.Name = testDeckName + " (Updated)";
await db.SaveChangesAsync();
test.AddLog("Updated deck with additional cards and divers.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Validating updated cards and divers...");
var fetched = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == testDeckId);
if (fetched == null || fetched.Cards?.Count != 6 || fetched.Divers?.Count != 2)
throw new Exception("Updated JSON collections did not persist correctly.");
test.AddLog("Updated JSON verified.");
test.AddLog("5. Deleting UserDeck...");
db.UserDecks.Remove(fetched);
await db.SaveChangesAsync();
test.AddLog("UserDeck deleted.");
}
await using (var db = _configService.CreateDbContext())
{
var exists = await db.UserDecks.AnyAsync(d => d.Id == testDeckId);
if (exists) throw new Exception("UserDeck still exists after deletion.");
test.AddLog("UserDeck deletion confirmed.");
}
}, progressCallback));
// 5. PasskeyCredential Binary Handling Test
results.Add(await RunTestCaseAsync("DB-05", "PasskeyCredential Binary Storage", "Passkeys",
"Validates byte array (byte[]) handling for CredentialId, PublicKey, and UserHandle.",
async test =>
{
var credId = new byte[] { 0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xDD, 0x10, 0x20, 0x30, 0x40 };
var pubKey = new byte[] { 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86 };
var userHandle = new byte[] { 0xFE, 0xDC, 0xBA, 0x98 };
var aaGuid = Guid.NewGuid().ToString();
long signCount = 100;
int savedId = 0;
await using (var db = _configService.CreateDbContext())
{
test.AddLog("1. Creating PasskeyCredential with raw byte arrays...");
var passkey = new PasskeyCredential
{
CredentialId = credId,
PublicKey = pubKey,
UserHandle = userHandle,
AaGuid = aaGuid,
SignCount = signCount
};
db.PasskeyCredentials.Add(passkey);
await db.SaveChangesAsync();
savedId = passkey.Id;
test.AddLog($"Passkey stored with generated ID: {savedId}");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Querying Passkey and verifying byte-for-byte exact equality...");
var fetched = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == savedId);
if (fetched == null) throw new Exception("Passkey not found after insertion.");
if (!fetched.CredentialId.SequenceEqual(credId))
throw new Exception("CredentialId byte array does not match original bytes.");
if (!fetched.PublicKey.SequenceEqual(pubKey))
throw new Exception("PublicKey byte array does not match original bytes.");
if (!fetched.UserHandle.SequenceEqual(userHandle))
throw new Exception("UserHandle byte array does not match original bytes.");
if (fetched.SignCount != signCount)
throw new Exception($"SignCount mismatch: expected {signCount}, got {fetched.SignCount}");
test.AddLog("Binary fields verified successfully.");
test.AddLog("3. Incrementing SignCount...");
fetched.SignCount += 1;
await db.SaveChangesAsync();
test.AddLog($"Updated SignCount to {fetched.SignCount}.");
}
await using (var db = _configService.CreateDbContext())
{
var fetched = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == savedId);
if (fetched == null || fetched.SignCount != 101)
throw new Exception("SignCount increment was not persisted.");
test.AddLog("4. Deleting Passkey...");
db.PasskeyCredentials.Remove(fetched);
await db.SaveChangesAsync();
test.AddLog("Passkey deleted.");
}
await using (var db = _configService.CreateDbContext())
{
var exists = await db.PasskeyCredentials.AnyAsync(p => p.Id == savedId);
if (exists) throw new Exception("Passkey still exists after deletion.");
test.AddLog("Passkey deletion confirmed.");
}
}, progressCallback));
// 6. Primary Key & Duplicate Key Constraint Test
results.Add(await RunTestCaseAsync("DB-06", "Primary Key & Duplicate Constraints", "Integrity",
"Verifies that attempting to insert a duplicate primary key throws DbUpdateException.",
async test =>
{
var duplicateKey = $"__DupTest_{Guid.NewGuid():N}";
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Inserting initial record with key '{duplicateKey}'...");
db.CardNotes.Add(new CardNote { CardName = duplicateKey, Note = "First note" });
await db.SaveChangesAsync();
test.AddLog("Initial record created.");
}
try
{
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Attempting to insert duplicate record with same primary key...");
db.CardNotes.Add(new CardNote { CardName = duplicateKey, Note = "Second duplicate note" });
await db.SaveChangesAsync();
}
throw new Exception("Expected exception on duplicate primary key, but operation succeeded!");
}
catch (DbUpdateException dbEx)
{
test.AddLog($"Expected DbUpdateException caught: {dbEx.Message}");
}
catch (ArgumentException argEx)
{
test.AddLog($"Expected ArgumentException caught: {argEx.Message}");
}
catch (InvalidOperationException invEx)
{
test.AddLog($"Expected key constraint exception caught: {invEx.Message}");
}
finally
{
// Cleanup
await using var db = _configService.CreateDbContext();
var toClean = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == duplicateKey);
if (toClean != null)
{
db.CardNotes.Remove(toClean);
await db.SaveChangesAsync();
test.AddLog("Cleaned up test record.");
}
}
}, progressCallback));
// 7. Transaction Rollback Verification
results.Add(await RunTestCaseAsync("DB-07", "Transaction Rollback Support", "Transactions",
"Verifies that uncommitted transactions roll back changes cleanly on failure.",
async test =>
{
if (_configService.ProviderType != DatabaseProviderType.PostgreSQL)
{
test.AddLog("Transaction testing requires relational provider (PostgreSQL). Skipping for In-Memory.");
return;
}
var transCard = $"__Trans_{Guid.NewGuid():N}";
await using (var db = _configService.CreateDbContext())
{
test.AddLog("1. Beginning transaction...");
await using var transaction = await db.Database.BeginTransactionAsync();
test.AddLog($"2. Inserting card '{transCard}' inside transaction...");
db.CardNotes.Add(new CardNote { CardName = transCard, Note = "Transaction Note" });
await db.SaveChangesAsync();
test.AddLog("3. Rolling back transaction explicitly without committing...");
await transaction.RollbackAsync();
test.AddLog("Transaction rolled back.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Verifying that the rolled back card note was NOT persisted...");
var exists = await db.CardNotes.AnyAsync(n => n.CardName == transCard);
if (exists)
throw new Exception("Record was persisted despite transaction rollback!");
test.AddLog("Rollback verified: record does not exist in database.");
}
}, progressCallback));
return results;
}
private static async Task<DatabaseTestCaseResult> RunTestCaseAsync(
string id,
string name,
string category,
string description,
Func<DatabaseTestCaseResult, Task> testAction,
Action<string>? progressCallback)
{
var test = new DatabaseTestCaseResult
{
Id = id,
Name = name,
Category = category,
Description = description,
Status = TestStatus.Running
};
progressCallback?.Invoke($"Running {name}...");
var sw = Stopwatch.StartNew();
try
{
await testAction(test);
sw.Stop();
test.DurationMs = sw.ElapsedMilliseconds;
test.Status = TestStatus.Passed;
test.AddLog($"Test PASSED in {test.DurationMs}ms.");
progressCallback?.Invoke($"Passed {name} ({test.DurationMs}ms)");
}
catch (Exception ex)
{
sw.Stop();
test.DurationMs = sw.ElapsedMilliseconds;
test.Status = TestStatus.Failed;
test.ErrorMessage = ex.Message;
test.StackTrace = ex.StackTrace;
test.AddLog($"Test FAILED: {ex.Message}");
progressCallback?.Invoke($"Failed {name}: {ex.Message}");
}
return test;
}
}