using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Couchbase.Lite; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using WorkTracker.Configuration; using WorkTracker.Domain; using WorkTracker.Services.Settings; using WorkTracker.Services.Storage; using Xunit; namespace WorkTracker.Tests; public sealed class JsonDatabaseBackupServiceTests { [Fact] public async Task ExportAndImport_RoundTripsManagedCollectionsAndVersions() { using var sourceDatabase = new TestDatabaseHandle(); await SeedAsync(sourceDatabase.Provider); var backupService = new JsonDatabaseBackupService(sourceDatabase.Provider); var backupFile = await backupService.ExportAsync(); using var backupJson = JsonDocument.Parse(backupFile.Content); Assert.Equal(1, backupJson.RootElement.GetProperty("backupFormatVersion").GetInt32()); Assert.Equal(CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion, backupJson.RootElement.GetProperty("databaseSchemaVersion").GetInt32()); Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("app_settings", out _)); Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("users", out _)); Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("workdays", out _)); using var targetDatabase = new TestDatabaseHandle(); var importService = new JsonDatabaseBackupService(targetDatabase.Provider); await using (var stream = new MemoryStream(backupFile.Content, writable: false)) { await importService.ImportAsync(stream); } var appSettingsService = new CouchbaseLiteAppSettingsService(targetDatabase.Provider); var importedSettings = await appSettingsService.GetAsync(); Assert.Equal(AppThemeMode.Dark, importedSettings.ThemeMode); Assert.Equal("USD", importedSettings.Currency); Assert.Equal("en-US", importedSettings.Locale); Assert.Equal(CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion, targetDatabase.Provider.DatabaseSchemaVersion); var importedUser = targetDatabase.Provider.Users.GetDocument("USER1"); Assert.NotNull(importedUser); Assert.Equal("admin", importedUser!.GetString("username")); var importedWorkDay = targetDatabase.Provider.WorkDays.GetDocument("2026-04-24"); Assert.NotNull(importedWorkDay); Assert.Equal("2026-04-24", importedWorkDay!.GetString("date")); } [Fact] public async Task Import_WhenSchemaVersionDoesNotMatch_RejectsWithoutOverwritingDatabase() { using var sourceDatabase = new TestDatabaseHandle(); await SeedAsync(sourceDatabase.Provider); var backupService = new JsonDatabaseBackupService(sourceDatabase.Provider); var backupFile = await backupService.ExportAsync(); var modifiedBackup = JsonNode.Parse(backupFile.Content)!; modifiedBackup["databaseSchemaVersion"] = CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion + 1; var modifiedContent = Encoding.UTF8.GetBytes(modifiedBackup.ToJsonString(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); using var targetDatabase = new TestDatabaseHandle(); SeedExistingTargetData(targetDatabase.Provider); var importService = new JsonDatabaseBackupService(targetDatabase.Provider); await using var stream = new MemoryStream(modifiedContent, writable: false); var exception = await Assert.ThrowsAsync(() => importService.ImportAsync(stream)); Assert.Contains("not supported", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.NotNull(targetDatabase.Provider.Users.GetDocument("KEEP")); Assert.Null(targetDatabase.Provider.Users.GetDocument("USER1")); } private static async Task SeedAsync(CouchbaseLiteDatabaseProvider provider) { var appSettingsService = new CouchbaseLiteAppSettingsService(provider); await appSettingsService.SaveAsync(new AppSettingsDocument { ThemeMode = AppThemeMode.Dark, Currency = "USD", Locale = "en-US", StandardWorkHoursPerDay = 7.5m, HourlyGrossRate = 30m, ProfitabilityCoefficient = 0.8m, InpsRate = 0.2m, SubstituteTaxRate = 0.1m }); var userDocument = new MutableDocument("USER1"); userDocument.SetString("username", "admin"); userDocument.SetString("usernameNormalized", "ADMIN"); userDocument.SetString("passwordHash", "hash"); userDocument.SetBoolean("mustChangePassword", false); provider.Users.Save(userDocument); var workDayDocument = new MutableDocument("2026-04-24"); workDayDocument.SetString("date", "2026-04-24"); workDayDocument.SetBoolean("isWeekend", false); workDayDocument.SetBoolean("isItalianFestivity", false); workDayDocument.SetArray("workUnits", new MutableArrayObject()); workDayDocument.SetArray("calendarEvents", new MutableArrayObject()); workDayDocument.SetString("createdAtUtc", "2026-04-24T08:00:00.0000000+00:00"); workDayDocument.SetString("updatedAtUtc", "2026-04-24T08:00:00.0000000+00:00"); provider.WorkDays.Save(workDayDocument); } private static void SeedExistingTargetData(CouchbaseLiteDatabaseProvider provider) { var existingUser = new MutableDocument("KEEP"); existingUser.SetString("username", "keep-me"); existingUser.SetString("usernameNormalized", "KEEP-ME"); existingUser.SetString("passwordHash", "keep-hash"); existingUser.SetBoolean("mustChangePassword", false); provider.Users.Save(existingUser); } private sealed class TestDatabaseHandle : IDisposable { private readonly string directoryPath; public TestDatabaseHandle() { directoryPath = Path.Combine(Path.GetTempPath(), "WorkTracker.Tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directoryPath); Provider = new CouchbaseLiteDatabaseProvider( Options.Create(new CouchbaseLiteOptions { Directory = directoryPath, DatabaseName = $"worktracker-tests-{Guid.NewGuid():N}" }), new TestHostEnvironment()); } public CouchbaseLiteDatabaseProvider Provider { get; } public void Dispose() { Provider.Dispose(); if (Directory.Exists(directoryPath)) { Directory.Delete(directoryPath, recursive: true); } } } private sealed class TestHostEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = "Development"; public string ApplicationName { get; set; } = "WorkTracker.Tests"; public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; } }