Test
This commit is contained in:
@@ -0,0 +1,972 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user