From e8bbae0496c5676b3edd28ec6c24b7028479ab5b Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 24 Apr 2026 11:13:32 +0200 Subject: [PATCH] feat: implement database backup and restore functionality with JSON support Co-authored-by: Copilot --- Components/Pages/Settings.razor | 106 +++++++++ Program.cs | 9 + .../Storage/CouchbaseLiteDatabaseProvider.cs | 44 ++++ Services/Storage/DatabaseBackupModels.cs | 38 ++++ Services/Storage/IDatabaseBackupService.cs | 8 + Services/Storage/JsonDatabaseBackupService.cs | 204 ++++++++++++++++++ docs/date-range-backup-spec.md | 81 +++++++ .../JsonDatabaseBackupServiceTests.cs | 167 ++++++++++++++ 8 files changed, 657 insertions(+) create mode 100644 Services/Storage/DatabaseBackupModels.cs create mode 100644 Services/Storage/IDatabaseBackupService.cs create mode 100644 Services/Storage/JsonDatabaseBackupService.cs create mode 100644 docs/date-range-backup-spec.md create mode 100644 tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 38d2689..64fca6e 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -1,8 +1,13 @@ @page "/settings" @attribute [Authorize] +@using Microsoft.AspNetCore.Components.Forms +@using WorkTracker.Services.Storage + @inject IAppSettingsService AppSettingsService +@inject IDatabaseBackupService DatabaseBackupService @inject AppThemeState ThemeState +@inject IJSRuntime JS Settings @@ -65,11 +70,60 @@ else } + +
+

Database backup

+

Export the full database as JSON or restore a previously exported JSON backup. Restore replaces the current database only when the backup format version and database schema version are supported.

+ +
+ Export JSON backup + Current database schema version: @DatabaseSchemaVersion +
+ +
+
+

Restore from JSON

+

This overwrites the existing database with the selected backup file.

+ +
+ + + @if (!string.IsNullOrWhiteSpace(selectedBackupFileName)) + { +
Selected file: @selectedBackupFileName
+ } + + +
+ + @if (!string.IsNullOrWhiteSpace(backupStatusMessage)) + { +
@backupStatusMessage
+ } + + @if (!string.IsNullOrWhiteSpace(backupErrorMessage)) + { +
@backupErrorMessage
+ } +
+
+
} @code { + private const long MaxBackupFileSize = 20 * 1024 * 1024; + private AppSettingsDocument? settings; private string? statusMessage; + private string? backupStatusMessage; + private string? backupErrorMessage; + private IBrowserFile? selectedBackupFile; + private string? selectedBackupFileName; + private bool isRestoring; + + private int DatabaseSchemaVersion => CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion; protected override async Task OnInitializedAsync() { @@ -87,4 +141,56 @@ else ThemeState.SetThemeMode(settings.ThemeMode); statusMessage = $"Saved at {DateTime.Now:t}"; } + + private void OnRestoreFileSelected(InputFileChangeEventArgs args) + { + selectedBackupFile = args.File; + selectedBackupFileName = args.File.Name; + backupStatusMessage = null; + backupErrorMessage = null; + } + + private async Task RestoreAsync() + { + if (selectedBackupFile is null) + { + backupErrorMessage = "Select a JSON backup file first."; + backupStatusMessage = null; + return; + } + + var confirmed = await JS.InvokeAsync("confirm", "Restore this backup and overwrite the current database?\nThis cannot be undone."); + if (!confirmed) + { + return; + } + + isRestoring = true; + backupStatusMessage = null; + backupErrorMessage = null; + + try + { + await using var stream = selectedBackupFile.OpenReadStream(MaxBackupFileSize); + await DatabaseBackupService.ImportAsync(stream); + + settings = await AppSettingsService.GetAsync(); + ThemeState.SetThemeMode(settings.ThemeMode); + selectedBackupFile = null; + selectedBackupFileName = null; + backupStatusMessage = $"Backup restored at {DateTime.Now:t}."; + } + catch (DatabaseBackupException exception) + { + backupErrorMessage = exception.Message; + } + catch (IOException) + { + backupErrorMessage = "The selected backup file is too large or could not be read."; + } + finally + { + isRestoring = false; + } + } } diff --git a/Program.cs b/Program.cs index 31e20e5..312d5e6 100644 --- a/Program.cs +++ b/Program.cs @@ -71,6 +71,7 @@ builder.Services.Configure(builder.Configuration.GetSectio builder.Services.Configure(builder.Configuration.GetSection(SingleUserOptions.SectionName)); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -264,6 +265,14 @@ app.MapGet("/api/monthly-timesheet/{year:int}/{month:int}/excel", async ( return Results.File(file.Content, file.ContentType, file.FileName); }).RequireAuthorization(); +app.MapGet("/api/database-backup/export", async ( + IDatabaseBackupService backupService, + CancellationToken cancellationToken) => +{ + var file = await backupService.ExportAsync(cancellationToken); + return Results.File(file.Content, file.ContentType, file.FileName); +}).RequireAuthorization(); + // Development-only endpoint to reset the seeded Admin password (protected by secret in URL) if (app.Environment.IsDevelopment()) { diff --git a/Services/Storage/CouchbaseLiteDatabaseProvider.cs b/Services/Storage/CouchbaseLiteDatabaseProvider.cs index 18126d5..4fd68f3 100644 --- a/Services/Storage/CouchbaseLiteDatabaseProvider.cs +++ b/Services/Storage/CouchbaseLiteDatabaseProvider.cs @@ -7,10 +7,15 @@ namespace WorkTracker.Services.Storage; public sealed class CouchbaseLiteDatabaseProvider : IDisposable { private const string AppSettingsCollectionName = "app_settings"; + private const string SystemCollectionName = "system"; private const string UsersCollectionName = "users"; private const string WorkDaysCollectionName = "workdays"; + private const string SchemaVersionDocumentId = "database_schema"; + + public const int CurrentDatabaseSchemaVersion = 1; private readonly Database database; + private readonly IReadOnlyDictionary backupCollections; public CouchbaseLiteDatabaseProvider(IOptions options, IHostEnvironment environment) { @@ -25,17 +30,39 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable Directory = databaseDirectory }); + System = database.GetCollection(SystemCollectionName) ?? database.CreateCollection(SystemCollectionName); AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName); Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName); WorkDays = database.GetCollection(WorkDaysCollectionName) ?? database.CreateCollection(WorkDaysCollectionName); + + backupCollections = new Dictionary(StringComparer.Ordinal) + { + [AppSettingsCollectionName] = AppSettings, + [UsersCollectionName] = Users, + [WorkDaysCollectionName] = WorkDays + }; + + DatabaseSchemaVersion = EnsureDatabaseSchemaVersion(); } + public Collection System { get; } + public Collection AppSettings { get; } public Collection Users { get; } public Collection WorkDays { get; } + public int DatabaseSchemaVersion { get; } + + public IReadOnlyDictionary BackupCollections => backupCollections; + + public void ExecuteInBatch(Action action) + { + ArgumentNullException.ThrowIfNull(action); + database.InBatch(action); + } + public void Dispose() { database.Close(); @@ -53,4 +80,21 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable ? configuredDirectory : Path.GetFullPath(Path.Combine(contentRootPath, configuredDirectory)); } + + private int EnsureDatabaseSchemaVersion() + { + var document = System.GetDocument(SchemaVersionDocumentId); + if (document is not null && document.Contains("schemaVersion")) + { + return document.GetInt("schemaVersion"); + } + + var now = DateTimeOffset.UtcNow; + var mutableDocument = new MutableDocument(SchemaVersionDocumentId); + mutableDocument.SetInt("schemaVersion", CurrentDatabaseSchemaVersion); + mutableDocument.SetString("createdAtUtc", now.ToString("O")); + mutableDocument.SetString("updatedAtUtc", now.ToString("O")); + System.Save(mutableDocument); + return CurrentDatabaseSchemaVersion; + } } \ No newline at end of file diff --git a/Services/Storage/DatabaseBackupModels.cs b/Services/Storage/DatabaseBackupModels.cs new file mode 100644 index 0000000..cbc0664 --- /dev/null +++ b/Services/Storage/DatabaseBackupModels.cs @@ -0,0 +1,38 @@ +using System.Text.Json; + +namespace WorkTracker.Services.Storage; + +public sealed class DatabaseBackupFile +{ + public required byte[] Content { get; init; } + + public required string ContentType { get; init; } + + public required string FileName { get; init; } +} + +public sealed class DatabaseBackupPayload +{ + public int BackupFormatVersion { get; init; } + + public int DatabaseSchemaVersion { get; init; } + + public DateTimeOffset ExportedAtUtc { get; init; } + + public Dictionary> Collections { get; init; } = []; +} + +public sealed class DatabaseBackupDocumentPayload +{ + public string Id { get; init; } = string.Empty; + + public JsonElement Data { get; init; } +} + +public sealed class DatabaseBackupException : Exception +{ + public DatabaseBackupException(string message) + : base(message) + { + } +} \ No newline at end of file diff --git a/Services/Storage/IDatabaseBackupService.cs b/Services/Storage/IDatabaseBackupService.cs new file mode 100644 index 0000000..eb6d256 --- /dev/null +++ b/Services/Storage/IDatabaseBackupService.cs @@ -0,0 +1,8 @@ +namespace WorkTracker.Services.Storage; + +public interface IDatabaseBackupService +{ + Task ExportAsync(CancellationToken cancellationToken = default); + + Task ImportAsync(Stream jsonStream, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Services/Storage/JsonDatabaseBackupService.cs b/Services/Storage/JsonDatabaseBackupService.cs new file mode 100644 index 0000000..1e1ea74 --- /dev/null +++ b/Services/Storage/JsonDatabaseBackupService.cs @@ -0,0 +1,204 @@ +using System.Text; +using System.Text.Json; +using Couchbase.Lite; +using Couchbase.Lite.Query; + +namespace WorkTracker.Services.Storage; + +public sealed class JsonDatabaseBackupService(CouchbaseLiteDatabaseProvider databaseProvider) : IDatabaseBackupService +{ + private const int CurrentBackupFormatVersion = 1; + + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public Task ExportAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureSupportedDatabaseSchemaVersion(databaseProvider.DatabaseSchemaVersion); + + var payload = new DatabaseBackupPayload + { + BackupFormatVersion = CurrentBackupFormatVersion, + DatabaseSchemaVersion = databaseProvider.DatabaseSchemaVersion, + ExportedAtUtc = DateTimeOffset.UtcNow, + Collections = ExportCollections(cancellationToken) + }; + + var content = JsonSerializer.SerializeToUtf8Bytes(payload, JsonSerializerOptions); + var timestamp = payload.ExportedAtUtc.ToString("yyyyMMdd-HHmmss"); + + return Task.FromResult(new DatabaseBackupFile + { + Content = content, + ContentType = "application/json", + FileName = $"worktracker-backup-v{payload.DatabaseSchemaVersion}-{timestamp}.json" + }); + } + + public async Task ImportAsync(Stream jsonStream, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(jsonStream); + cancellationToken.ThrowIfCancellationRequested(); + + DatabaseBackupPayload payload; + + try + { + payload = await JsonSerializer.DeserializeAsync(jsonStream, JsonSerializerOptions, cancellationToken) + ?? throw new DatabaseBackupException("Backup file is empty or invalid."); + } + catch (JsonException exception) + { + throw new DatabaseBackupException($"Backup file is not valid JSON: {exception.Message}"); + } + + ValidatePayload(payload); + + databaseProvider.ExecuteInBatch(() => + { + foreach (var collection in databaseProvider.BackupCollections.Values) + { + ClearCollection(collection); + } + + foreach (var (collectionName, documents) in payload.Collections) + { + var collection = databaseProvider.BackupCollections[collectionName]; + + foreach (var document in documents) + { + var mutableDocument = new MutableDocument(document.Id); + mutableDocument.SetJSON(document.Data.GetRawText()); + collection.Save(mutableDocument); + } + } + }); + } + + private Dictionary> ExportCollections(CancellationToken cancellationToken) + { + var collections = new Dictionary>(StringComparer.Ordinal); + + foreach (var (collectionName, collection) in databaseProvider.BackupCollections) + { + cancellationToken.ThrowIfCancellationRequested(); + + collections[collectionName] = GetDocuments(collection, cancellationToken) + .Select(document => new DatabaseBackupDocumentPayload + { + Id = document.Id, + Data = ParseJson(document.ToJSON()) + }) + .ToList(); + } + + return collections; + } + + private void ValidatePayload(DatabaseBackupPayload payload) + { + if (payload.BackupFormatVersion <= 0) + { + throw new DatabaseBackupException("Backup file does not declare a supported format version."); + } + + EnsureSupportedBackupFormatVersion(payload.BackupFormatVersion); + EnsureSupportedDatabaseSchemaVersion(payload.DatabaseSchemaVersion); + + if (payload.DatabaseSchemaVersion != databaseProvider.DatabaseSchemaVersion) + { + throw new DatabaseBackupException($"Backup schema version {payload.DatabaseSchemaVersion} does not match database schema version {databaseProvider.DatabaseSchemaVersion}."); + } + + var expectedCollectionNames = databaseProvider.BackupCollections.Keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray(); + var actualCollectionNames = payload.Collections.Keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray(); + + if (!expectedCollectionNames.SequenceEqual(actualCollectionNames, StringComparer.Ordinal)) + { + throw new DatabaseBackupException("Backup file does not contain the expected collection set for this application version."); + } + + foreach (var (collectionName, documents) in payload.Collections) + { + foreach (var document in documents) + { + if (string.IsNullOrWhiteSpace(document.Id)) + { + throw new DatabaseBackupException($"Backup contains a document without an id in collection '{collectionName}'."); + } + + if (document.Data.ValueKind is not JsonValueKind.Object) + { + throw new DatabaseBackupException($"Backup document '{document.Id}' in collection '{collectionName}' must be a JSON object."); + } + } + } + } + + private static void EnsureSupportedBackupFormatVersion(int backupFormatVersion) + { + if (backupFormatVersion == CurrentBackupFormatVersion) + { + return; + } + + throw new DatabaseBackupException($"Backup format version {backupFormatVersion} is not supported by this build. Add a backup format migration before importing it."); + } + + private static void EnsureSupportedDatabaseSchemaVersion(int databaseSchemaVersion) + { + if (databaseSchemaVersion == CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion) + { + return; + } + + throw new DatabaseBackupException($"Database schema version {databaseSchemaVersion} is not supported by this build. Add a schema migration before using this backup."); + } + + private static List GetDocuments(Collection collection, CancellationToken cancellationToken) + { + var query = QueryBuilder + .Select(SelectResult.Expression(Meta.ID)) + .From(DataSource.Collection(collection)) + .OrderBy(Ordering.Expression(Meta.ID)); + + var documents = new List(); + foreach (var result in query.Execute()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var id = result.GetString(0); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + var document = collection.GetDocument(id); + if (document is not null) + { + documents.Add(document); + } + } + + return documents; + } + + private static void ClearCollection(Collection collection) + { + foreach (var document in GetDocuments(collection, CancellationToken.None)) + { + collection.Delete(document); + } + } + + private static JsonElement ParseJson(string json) + { + using var jsonDocument = JsonDocument.Parse(json); + return jsonDocument.RootElement.Clone(); + } +} \ No newline at end of file diff --git a/docs/date-range-backup-spec.md b/docs/date-range-backup-spec.md new file mode 100644 index 0000000..3b373ad --- /dev/null +++ b/docs/date-range-backup-spec.md @@ -0,0 +1,81 @@ +# Date-Range Backup Spec + +## Status + +- Draft only. +- Do not implement until full-backup import/export has been exercised in real usage. + +## Goal + +Allow users to export and import only a selected time range without weakening the safety guarantees introduced by full JSON backups. + +## Non-goals + +- No implementation in this change. +- No automatic migration across JSON backup format versions. +- No merging logic for conflicting edits yet. + +## Proposed backup modes + +- `full`: current behavior, exports every managed collection and can fully replace the database. +- `dateRange`: future behavior, exports only documents relevant to a user-selected inclusive date interval. + +## Proposed JSON shape changes + +Add a top-level `scope` object while keeping the existing version markers. + +```json +{ + "backupFormatVersion": 1, + "databaseSchemaVersion": 1, + "exportedAtUtc": "2026-04-24T12:00:00Z", + "scope": { + "mode": "full" + }, + "collections": { + "app_settings": [], + "users": [], + "workdays": [] + } +} +``` + +Future date-range backups would use: + +```json +"scope": { + "mode": "dateRange", + "from": "2026-04-01", + "to": "2026-04-30" +} +``` + +## Selection rules for `dateRange` + +- `workdays`: include documents whose logical day falls within `from` and `to` inclusive. +- `app_settings`: exclude by default. Settings are global, not time-scoped. +- `users`: exclude by default. Authentication data is global, not time-scoped. +- Future collections must declare whether they are `global`, `timeScoped`, or `derived` before they participate in range backups. + +## Import semantics for `dateRange` + +- Import must not reuse the current full-overwrite flow. +- Import must require a different confirmation message than full restore. +- Import must define conflict rules before implementation. +- Preferred first version: replace only the selected date range inside time-scoped collections. +- Global collections must remain untouched unless explicitly opted in by the user and supported by the backup format version. + +## Compatibility and versioning requirements + +- Keep `backupFormatVersion` and `databaseSchemaVersion` mandatory for every backup mode. +- If `scope.mode` is unknown, reject import. +- If `backupFormatVersion` is unsupported, reject import and surface that a migration/importer is required. +- If `databaseSchemaVersion` is unsupported, reject import and surface that a schema migration is required. +- When date-range import is implemented, version-specific import handlers should be introduced behind a dispatch layer such as `ImportV1Full`, `ImportV1DateRange`, and future `ImportV2*` handlers. + +## UI requirements for future work + +- Add date pickers for `from` and `to`. +- Validate `from <= to` before export/import is enabled. +- Show a summary of what will be affected before confirming import. +- Keep full-backup export/import available as the safer recovery path. \ No newline at end of file diff --git a/tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs b/tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs new file mode 100644 index 0000000..4967412 --- /dev/null +++ b/tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs @@ -0,0 +1,167 @@ +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!; + } +} \ No newline at end of file