From 456d5edf9892a7ee7da1e8bf6d6958bc6a900323 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 27 May 2026 09:47:55 +0100 Subject: [PATCH 1/2] test(persistence): add failing integration tests for repository layer (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED commit — stubs throw NotImplementedException; integration tests fail. Failing tests: - GivenAnAccountRepository: 4 tests - GivenASyncRuleRepository: 2 tests Co-Authored-By: Claude Sonnet 4.6 --- AStar.Dev.CloudSync.slnx | 1 + .../AStar.Dev.CloudSyncFunctional.csproj | 7 +- .../Onboarding/PersistenceError.cs | 38 ++++++++++ .../Persistence/AppDbContext.cs | 36 +++++++++ .../AccountEntityConfiguration.cs | 27 +++++++ .../DriveStateEntityConfiguration.cs | 16 ++++ ...leClassificationRuleEntityConfiguration.cs | 13 ++++ .../SyncConflictEntityConfiguration.cs | 17 +++++ .../SyncJobEntityConfiguration.cs | 17 +++++ .../SyncRuleEntityConfiguration.cs | 17 +++++ ...edItemClassificationEntityConfiguration.cs | 16 ++++ .../SyncedItemEntityConfiguration.cs | 17 +++++ .../Persistence/Entities/AccountEntity.cs | 25 +++++++ .../Entities/AccountProfileEntity.cs | 13 ++++ .../Persistence/Entities/AccountSyncConfig.cs | 13 ++++ .../Persistence/Entities/DriveStateEntity.cs | 16 ++++ .../Entities/FileClassificationRuleEntity.cs | 17 +++++ .../Persistence/Entities/RuleType.cs | 11 +++ .../Entities/SyncConflictEntity.cs | 25 +++++++ .../Persistence/Entities/SyncJobEntity.cs | 28 +++++++ .../Persistence/Entities/SyncRuleEntity.cs | 19 +++++ .../SyncedItemClassificationEntity.cs | 16 ++++ .../Persistence/Entities/SyncedItemEntity.cs | 28 +++++++ .../Repositories/AccountRepository.cs | 27 +++++++ .../Repositories/DriveStateRepository.cs | 19 +++++ .../FileClassificationRuleRepository.cs | 22 ++++++ .../Repositories/IAccountRepository.cs | 33 ++++++++ .../Repositories/IDriveStateRepository.cs | 22 ++++++ .../IFileClassificationRuleRepository.cs | 26 +++++++ .../Repositories/ISyncRepository.cs | 40 ++++++++++ .../Repositories/ISyncRuleRepository.cs | 28 +++++++ .../Repositories/ISyncedItemRepository.cs | 34 +++++++++ .../Repositories/SyncRepository.cs | 31 ++++++++ .../Repositories/SyncRuleRepository.cs | 23 ++++++ .../Repositories/SyncedItemRepository.cs | 27 +++++++ .../Persistence/SqliteTypeConverters.cs | 65 ++++++++++++++++ .../Persistence/ValueObjects/AccountId.cs | 4 + .../Persistence/ValueObjects/DisplayName.cs | 4 + .../Persistence/ValueObjects/DriveId.cs | 4 + .../Persistence/ValueObjects/EmailAddress.cs | 4 + .../Persistence/ValueObjects/LocalPath.cs | 4 + .../Persistence/ValueObjects/LocalSyncPath.cs | 4 + .../ValueObjects/OneDriveFolderId.cs | 4 + .../ValueObjects/OneDriveItemId.cs | 4 + .../Persistence/ValueObjects/RemotePath.cs | 4 + .../ValueObjects/SyncConflictId.cs | 4 + .../Persistence/ValueObjects/SyncJobId.cs | 4 + .../Persistence/ValueObjects/SyncRuleId.cs | 4 + .../Persistence/ValueObjects/SyncedItemId.cs | 4 + ...oudSyncFunctional.Tests.Integration.csproj | 36 +++++++++ .../Repositories/GivenASyncRuleRepository.cs | 47 ++++++++++++ .../Repositories/GivenAnAccountRepository.cs | 75 +++++++++++++++++++ .../TestData/DatabaseFixture.cs | 28 +++++++ .../TestData/TestDbContextFactory.cs | 19 +++++ 54 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContext.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/FileClassificationRuleEntityConfiguration.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountProfileEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountSyncConfig.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/DriveStateEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/FileClassificationRuleEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/RuleType.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncConflictEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncJobEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncRuleEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemClassificationEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemEntity.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IAccountRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IDriveStateRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IFileClassificationRuleRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRuleRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncedItemRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/SqliteTypeConverters.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/AccountId.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/DisplayName.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/DriveId.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/EmailAddress.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/LocalPath.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/LocalSyncPath.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/OneDriveFolderId.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/OneDriveItemId.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/RemotePath.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncConflictId.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncJobId.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncRuleId.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncedItemId.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRuleRepository.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenAnAccountRepository.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/DatabaseFixture.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/TestDbContextFactory.cs diff --git a/AStar.Dev.CloudSync.slnx b/AStar.Dev.CloudSync.slnx index ddee98d..f6cd9d9 100644 --- a/AStar.Dev.CloudSync.slnx +++ b/AStar.Dev.CloudSync.slnx @@ -6,5 +6,6 @@ + diff --git a/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj b/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj index fac43a9..03230c3 100644 --- a/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj +++ b/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj @@ -6,7 +6,7 @@ enable app.manifest true - NU1903 + NU1903;CS9113 astar-dev-cloudsync-functional @@ -33,6 +33,11 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs index 057cd7a..6d088f9 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs @@ -17,3 +17,41 @@ public sealed record PersistenceUnexpectedError : PersistenceError /// The error message. public PersistenceUnexpectedError(string message) => Message = message; } + +/// A concurrency conflict occurred — the record was modified by another operation. +public sealed record ConcurrencyConflictError : PersistenceError +{ + /// + public override string Message => "A concurrency conflict occurred. The record was modified by another operation."; +} + +/// A database constraint was violated during a persistence operation. +public sealed record ConstraintViolationError : PersistenceError +{ + /// + public override string Message { get; } + + /// Initialises a new with the given detail. + /// A description of the constraint that was violated. + public ConstraintViolationError(string detail) => Message = detail; +} + +/// Static factory for constructing instances. +public static class PersistenceErrorFactory +{ + /// Creates a . + /// A new concurrency conflict error. + public static PersistenceError ConcurrencyConflict() => new ConcurrencyConflictError(); + + /// Creates a . + /// Optional detail describing the violated constraint. + /// A new constraint violation error. + public static PersistenceError ConstraintViolation(string? detail) => + new ConstraintViolationError(string.IsNullOrWhiteSpace(detail) ? "A constraint violation occurred: unknown error." : detail); + + /// Creates a . + /// Optional message describing the unexpected error. + /// A new unexpected persistence error. + public static PersistenceError Unexpected(string? message) => + new PersistenceUnexpectedError(string.IsNullOrWhiteSpace(message) ? "An unexpected persistence error occurred: unknown error." : message); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContext.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContext.cs new file mode 100644 index 0000000..995978c --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContext.cs @@ -0,0 +1,36 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Persistence; + +/// EF Core database context for the cloud sync application. +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + /// Gets the accounts dataset. + public DbSet Accounts => Set(); + + /// Gets the sync rules dataset. + public DbSet SyncRules => Set(); + + /// Gets the synced items dataset. + public DbSet SyncedItems => Set(); + + /// Gets the synced item classifications dataset. + public DbSet SyncedItemClassifications => Set(); + + /// Gets the file classification rules dataset. + public DbSet FileClassificationRules => Set(); + + /// Gets the sync jobs dataset. + public DbSet SyncJobs => Set(); + + /// Gets the sync conflicts dataset. + public DbSet SyncConflicts => Set(); + + /// Gets the drive states dataset. + public DbSet DriveStates => Set(); + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs new file mode 100644 index 0000000..cc49604 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs @@ -0,0 +1,27 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration; + +/// EF Core entity configuration for . +public sealed class AccountEntityConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.AccountIdConverter); + builder.Property(e => e.DriveId).HasConversion(SqliteTypeConverters.DriveIdConverter); + builder.ComplexProperty(e => e.Profile, p => + { + p.Property(prof => prof.DisplayName).HasConversion(SqliteTypeConverters.DisplayNameConverter).HasColumnName("DisplayName"); + p.Property(prof => prof.Email).HasConversion(SqliteTypeConverters.EmailAddressConverter).HasColumnName("Email"); + }); + builder.ComplexProperty(e => e.SyncConfig, sc => + { + sc.Property(c => c.LocalSyncPath).HasConversion(SqliteTypeConverters.LocalSyncPathConverter).HasColumnName("LocalSyncPath"); + sc.Property(c => c.WorkerCount).HasColumnName("WorkerCount"); + }); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs new file mode 100644 index 0000000..c58fd1e --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs @@ -0,0 +1,16 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration; + +/// EF Core entity configuration for . +public sealed class DriveStateEntityConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.AccountId); + builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/FileClassificationRuleEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/FileClassificationRuleEntityConfiguration.cs new file mode 100644 index 0000000..b73a91e --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/FileClassificationRuleEntityConfiguration.cs @@ -0,0 +1,13 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration; + +/// EF Core entity configuration for . +public sealed class FileClassificationRuleEntityConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + => builder.HasKey(e => e.Id); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs new file mode 100644 index 0000000..872cd79 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs @@ -0,0 +1,17 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration; + +/// EF Core entity configuration for . +public sealed class SyncConflictEntityConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncConflictIdConverter); + builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs new file mode 100644 index 0000000..6ace8f3 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs @@ -0,0 +1,17 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration; + +/// EF Core entity configuration for . +public sealed class SyncJobEntityConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncJobIdConverter); + builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs new file mode 100644 index 0000000..e21a084 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs @@ -0,0 +1,17 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration; + +/// EF Core entity configuration for . +public sealed class SyncRuleEntityConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncRuleIdConverter); + builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs new file mode 100644 index 0000000..8139af3 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs @@ -0,0 +1,16 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration; + +/// EF Core entity configuration for . +public sealed class SyncedItemClassificationEntityConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.SyncedItemId).HasConversion(SqliteTypeConverters.SyncedItemIdConverter); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs new file mode 100644 index 0000000..05ee639 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs @@ -0,0 +1,17 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration; + +/// EF Core entity configuration for . +public sealed class SyncedItemEntityConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncedItemIdConverter); + builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountEntity.cs new file mode 100644 index 0000000..c16bbee --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountEntity.cs @@ -0,0 +1,25 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// EF Core persistence entity for an OneDrive account. +public sealed class AccountEntity +{ + /// Gets or sets the MSAL HomeAccountId identifier. + public AccountId Id { get; set; } + + /// Gets or sets the account profile (display name, email). + public AccountProfileEntity Profile { get; set; } = new(); + + /// Gets or sets whether this account is active for sync. + public bool IsActive { get; set; } + + /// Gets or sets the Graph drive ID. + public DriveId DriveId { get; set; } + + /// Gets or sets the sync configuration. + public AccountSyncConfig SyncConfig { get; set; } = new(); + + /// Gets or sets the UTC timestamp of the last successful sync. + public DateTimeOffset? LastSyncedAt { get; set; } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountProfileEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountProfileEntity.cs new file mode 100644 index 0000000..4a51141 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountProfileEntity.cs @@ -0,0 +1,13 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// Profile information stored as a complex property on . +public sealed class AccountProfileEntity +{ + /// Gets or sets the display name. + public DisplayName DisplayName { get; set; } + + /// Gets or sets the email address. + public EmailAddress Email { get; set; } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountSyncConfig.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountSyncConfig.cs new file mode 100644 index 0000000..85d6f1b --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountSyncConfig.cs @@ -0,0 +1,13 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// Sync configuration stored as a complex property on . +public sealed class AccountSyncConfig +{ + /// Gets or sets the local folder where files are synced. + public LocalSyncPath LocalSyncPath { get; set; } + + /// Gets or sets the number of parallel sync workers (1–10). + public int WorkerCount { get; set; } = 8; +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/DriveStateEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/DriveStateEntity.cs new file mode 100644 index 0000000..19485bf --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/DriveStateEntity.cs @@ -0,0 +1,16 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// EF Core persistence entity for the cached drive state of an account. +public sealed class DriveStateEntity +{ + /// Gets or sets the account identifier (primary key and foreign key). + public AccountId AccountId { get; set; } + + /// Gets or sets the delta link for incremental change enumeration. + public string DeltaLink { get; set; } = string.Empty; + + /// Gets or sets the UTC timestamp of the last delta check. + public DateTimeOffset LastCheckedAt { get; set; } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/FileClassificationRuleEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/FileClassificationRuleEntity.cs new file mode 100644 index 0000000..f063d97 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/FileClassificationRuleEntity.cs @@ -0,0 +1,17 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// EF Core persistence entity for a file classification rule. +public sealed class FileClassificationRuleEntity +{ + /// Gets or sets the rule identifier. + public string Id { get; set; } = string.Empty; + + /// Gets or sets the human-readable name for this rule. + public string Name { get; set; } = string.Empty; + + /// Gets or sets the classification label applied when this rule matches. + public string Classification { get; set; } = string.Empty; + + /// Gets or sets the keywords that trigger this rule, stored as a comma-delimited string. + public string Keywords { get; set; } = string.Empty; +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/RuleType.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/RuleType.cs new file mode 100644 index 0000000..e1cdabc --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/RuleType.cs @@ -0,0 +1,11 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// Indicates whether a sync rule includes or excludes a path. +public enum RuleType +{ + /// The path is included in sync. + Include, + + /// The path is excluded from sync. + Exclude +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncConflictEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncConflictEntity.cs new file mode 100644 index 0000000..ac72a82 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncConflictEntity.cs @@ -0,0 +1,25 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// EF Core persistence entity for a detected sync conflict. +public sealed class SyncConflictEntity +{ + /// Gets or sets the sync conflict identifier. + public SyncConflictId Id { get; set; } + + /// Gets or sets the account this conflict belongs to. + public AccountId AccountId { get; set; } + + /// Gets or sets the remote OneDrive item identifier involved in the conflict. + public string RemoteItemId { get; set; } = string.Empty; + + /// Gets or sets the UTC timestamp of the local file at the time of conflict detection. + public DateTimeOffset LocalModifiedAt { get; set; } + + /// Gets or sets the UTC timestamp of the remote file at the time of conflict detection. + public DateTimeOffset RemoteModifiedAt { get; set; } + + /// Gets or sets the resolution state — "Pending" or "Resolved". + public string State { get; set; } = "Pending"; +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncJobEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncJobEntity.cs new file mode 100644 index 0000000..d94cfd1 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncJobEntity.cs @@ -0,0 +1,28 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// EF Core persistence entity for a pending or completed sync job. +public sealed class SyncJobEntity +{ + /// Gets or sets the sync job identifier. + public SyncJobId Id { get; set; } + + /// Gets or sets the account this job belongs to. + public AccountId AccountId { get; set; } + + /// Gets or sets the remote OneDrive path involved in this job. + public string RemotePath { get; set; } = string.Empty; + + /// Gets or sets the local file system path involved in this job. + public string LocalPath { get; set; } = string.Empty; + + /// Gets or sets the type of job — "Download" or "Upload". + public string JobType { get; set; } = string.Empty; + + /// Gets or sets the current status — "Pending", "Running", "Completed", or "Failed". + public string Status { get; set; } = string.Empty; + + /// Gets or sets the UTC timestamp when this job was created. + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncRuleEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncRuleEntity.cs new file mode 100644 index 0000000..89f7197 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncRuleEntity.cs @@ -0,0 +1,19 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// EF Core persistence entity for a sync rule. +public sealed class SyncRuleEntity +{ + /// Gets or sets the sync rule identifier. + public SyncRuleId Id { get; set; } + + /// Gets or sets the account this rule belongs to. + public AccountId AccountId { get; set; } + + /// Gets or sets the remote OneDrive path this rule applies to. + public string RemotePath { get; set; } = string.Empty; + + /// Gets or sets whether this rule includes or excludes the path. + public RuleType RuleType { get; set; } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemClassificationEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemClassificationEntity.cs new file mode 100644 index 0000000..a37bb36 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemClassificationEntity.cs @@ -0,0 +1,16 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// EF Core persistence entity for a classification applied to a synced item. +public sealed class SyncedItemClassificationEntity +{ + /// Gets or sets the identifier of the classification record. + public string Id { get; set; } = string.Empty; + + /// Gets or sets the synced item this classification belongs to. + public SyncedItemId SyncedItemId { get; set; } + + /// Gets or sets the classification label. + public string Classification { get; set; } = string.Empty; +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemEntity.cs new file mode 100644 index 0000000..eb28d13 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemEntity.cs @@ -0,0 +1,28 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities; + +/// EF Core persistence entity for a synced file or folder item. +public sealed class SyncedItemEntity +{ + /// Gets or sets the synced item identifier. + public SyncedItemId Id { get; set; } + + /// Gets or sets the account this item belongs to. + public AccountId AccountId { get; set; } + + /// Gets or sets the remote OneDrive path of this item. + public string RemotePath { get; set; } = string.Empty; + + /// Gets or sets the local file system path of this item. + public string LocalPath { get; set; } = string.Empty; + + /// Gets or sets the UTC timestamp of the last known remote modification. + public DateTimeOffset RemoteModifiedAt { get; set; } + + /// Gets or sets the remote eTag for conflict detection. + public string? ETag { get; set; } + + /// Gets or sets whether this item is a folder. + public bool IsFolder { get; set; } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs new file mode 100644 index 0000000..35a142c --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs @@ -0,0 +1,27 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// +public sealed class AccountRepository(IDbContextFactory dbFactory) : IAccountRepository +{ + /// + public Task> GetByIdAsync(AccountId id, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> GetAllAsync(CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> UpsertAsync(AccountEntity entity, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> DeleteAsync(AccountId id, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs new file mode 100644 index 0000000..4e2aa0f --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs @@ -0,0 +1,19 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// +public sealed class DriveStateRepository(IDbContextFactory dbFactory) : IDriveStateRepository +{ + /// + public Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> UpsertAsync(DriveStateEntity entity, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs new file mode 100644 index 0000000..b5113c5 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs @@ -0,0 +1,22 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.FunctionalParadigm; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// +public sealed class FileClassificationRuleRepository(IDbContextFactory dbFactory) : IFileClassificationRuleRepository +{ + /// + public Task> GetAllAsync(CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> UpsertAsync(FileClassificationRuleEntity entity, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> DeleteAsync(string id, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IAccountRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IAccountRepository.cs new file mode 100644 index 0000000..2173adb --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IAccountRepository.cs @@ -0,0 +1,33 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// Persistence contract for operations. +public interface IAccountRepository +{ + /// Retrieves an account by its identifier. + /// The account identifier. + /// Cancellation token. + /// The account if found, otherwise None. + Task> GetByIdAsync(AccountId id, CancellationToken ct = default); + + /// Retrieves all accounts. + /// Cancellation token. + /// All stored accounts. + Task> GetAllAsync(CancellationToken ct = default); + + /// Upserts an account. + /// The account to upsert. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> UpsertAsync(AccountEntity entity, CancellationToken ct = default); + + /// Deletes an account and all its child entities. + /// The account identifier. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> DeleteAsync(AccountId id, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IDriveStateRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IDriveStateRepository.cs new file mode 100644 index 0000000..c9c3bb6 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IDriveStateRepository.cs @@ -0,0 +1,22 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// Persistence contract for operations. +public interface IDriveStateRepository +{ + /// Retrieves the drive state for a given account. + /// The account identifier. + /// Cancellation token. + /// The drive state if present, otherwise None. + Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default); + + /// Upserts the drive state for an account. + /// The drive state to upsert. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> UpsertAsync(DriveStateEntity entity, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IFileClassificationRuleRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IFileClassificationRuleRepository.cs new file mode 100644 index 0000000..b473737 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/IFileClassificationRuleRepository.cs @@ -0,0 +1,26 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// Persistence contract for operations. +public interface IFileClassificationRuleRepository +{ + /// Retrieves all file classification rules. + /// Cancellation token. + /// All stored classification rules. + Task> GetAllAsync(CancellationToken ct = default); + + /// Upserts a file classification rule. + /// The rule to upsert. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> UpsertAsync(FileClassificationRuleEntity entity, CancellationToken ct = default); + + /// Deletes a file classification rule by identifier. + /// The rule identifier. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> DeleteAsync(string id, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRepository.cs new file mode 100644 index 0000000..b762176 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRepository.cs @@ -0,0 +1,40 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// Persistence contract for sync conflict and job operations. +public interface ISyncRepository +{ + /// Retrieves all pending conflicts for a given account. + /// The account identifier. + /// Cancellation token. + /// All pending conflicts for the account. + Task> GetPendingConflictsAsync(AccountId accountId, CancellationToken ct = default); + + /// Upserts a sync conflict. + /// The conflict to upsert. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> UpsertConflictAsync(SyncConflictEntity entity, CancellationToken ct = default); + + /// Marks a conflict as resolved. + /// The conflict identifier. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> ResolveConflictAsync(SyncConflictId id, CancellationToken ct = default); + + /// Upserts a sync job. + /// The job to upsert. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> UpsertJobAsync(SyncJobEntity entity, CancellationToken ct = default); + + /// Removes all completed jobs for a given account. + /// The account identifier. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> ClearCompletedJobsAsync(AccountId accountId, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRuleRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRuleRepository.cs new file mode 100644 index 0000000..f63b51f --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncRuleRepository.cs @@ -0,0 +1,28 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// Persistence contract for operations. +public interface ISyncRuleRepository +{ + /// Retrieves all sync rules for a given account. + /// The account identifier. + /// Cancellation token. + /// All sync rules for the account. + Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default); + + /// Upserts a sync rule. + /// The sync rule to upsert. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> UpsertAsync(SyncRuleEntity entity, CancellationToken ct = default); + + /// Deletes a sync rule by identifier. + /// The sync rule identifier. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> DeleteAsync(SyncRuleId id, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncedItemRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncedItemRepository.cs new file mode 100644 index 0000000..9819ddd --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/ISyncedItemRepository.cs @@ -0,0 +1,34 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// Persistence contract for operations. +public interface ISyncedItemRepository +{ + /// Retrieves a synced item by its identifier. + /// The synced item identifier. + /// Cancellation token. + /// The item if found, otherwise None. + Task> GetByIdAsync(SyncedItemId id, CancellationToken ct = default); + + /// Retrieves all synced items for a given account. + /// The account identifier. + /// Cancellation token. + /// All synced items for the account. + Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default); + + /// Upserts a synced item. + /// The item to upsert. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> UpsertAsync(SyncedItemEntity entity, CancellationToken ct = default); + + /// Deletes a synced item by identifier. + /// The item identifier. + /// Cancellation token. + /// Ok on success, Fail on error. + Task> DeleteAsync(SyncedItemId id, CancellationToken ct = default); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs new file mode 100644 index 0000000..037aa9e --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs @@ -0,0 +1,31 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// +public sealed class SyncRepository(IDbContextFactory dbFactory) : ISyncRepository +{ + /// + public Task> GetPendingConflictsAsync(AccountId accountId, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> UpsertConflictAsync(SyncConflictEntity entity, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> ResolveConflictAsync(SyncConflictId id, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> UpsertJobAsync(SyncJobEntity entity, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> ClearCompletedJobsAsync(AccountId accountId, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs new file mode 100644 index 0000000..1b9190c --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs @@ -0,0 +1,23 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// +public sealed class SyncRuleRepository(IDbContextFactory dbFactory) : ISyncRuleRepository +{ + /// + public Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> UpsertAsync(SyncRuleEntity entity, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> DeleteAsync(SyncRuleId id, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs new file mode 100644 index 0000000..210c9cb --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs @@ -0,0 +1,27 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.FunctionalParadigm; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; + +/// +public sealed class SyncedItemRepository(IDbContextFactory dbFactory) : ISyncedItemRepository +{ + /// + public Task> GetByIdAsync(SyncedItemId id, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> UpsertAsync(SyncedItemEntity entity, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); + + /// + public Task> DeleteAsync(SyncedItemId id, CancellationToken ct = default) + => throw new NotImplementedException("Not yet implemented"); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/SqliteTypeConverters.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/SqliteTypeConverters.cs new file mode 100644 index 0000000..b19547f --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/SqliteTypeConverters.cs @@ -0,0 +1,65 @@ +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace AStar.Dev.CloudSyncFunctional.Persistence; + +/// Centralised EF Core value converters for SQLite-incompatible .NET types. +public static class SqliteTypeConverters +{ + /// Converts to and from . + public static ValueConverter AccountIdConverter { get; } = + new(id => id.Value, str => new AccountId(str)); + + /// Converts to and from . + public static ValueConverter DriveIdConverter { get; } = + new(id => id.Value, str => new DriveId(str)); + + /// Converts to and from . + public static ValueConverter OneDriveItemIdConverter { get; } = + new(id => id.Value, str => new OneDriveItemId(str)); + + /// Converts to and from . + public static ValueConverter OneDriveFolderIdConverter { get; } = + new(id => id.Value, str => new OneDriveFolderId(str)); + + /// Converts to and from . + public static ValueConverter SyncRuleIdConverter { get; } = + new(id => id.Value, str => new SyncRuleId(str)); + + /// Converts to and from . + public static ValueConverter SyncedItemIdConverter { get; } = + new(id => id.Value, str => new SyncedItemId(str)); + + /// Converts to and from . + public static ValueConverter SyncJobIdConverter { get; } = + new(id => id.Value, str => new SyncJobId(str)); + + /// Converts to and from . + public static ValueConverter SyncConflictIdConverter { get; } = + new(id => id.Value, str => new SyncConflictId(str)); + + /// Converts to and from . + public static ValueConverter EmailAddressConverter { get; } = + new(e => e.Value, str => new EmailAddress(str)); + + /// Converts to and from . + public static ValueConverter DisplayNameConverter { get; } = + new(d => d.Value, str => new DisplayName(str)); + + /// Converts to and from . + public static ValueConverter LocalPathConverter { get; } = + new(p => p.Value, str => new LocalPath(str)); + + /// Converts to and from . + public static ValueConverter LocalSyncPathConverter { get; } = + new(p => p.Value, str => new LocalSyncPath(str)); + + /// Converts to and from . + public static ValueConverter RemotePathConverter { get; } = + new(p => p.Value, str => new RemotePath(str)); + + /// Converts a nullable to and from a nullable UTC ticks . + public static ValueConverter NullableDateTimeOffsetToTicks { get; } = + new(dt => dt.HasValue ? dt.Value.UtcTicks : (long?)null, + ticks => ticks.HasValue ? new DateTimeOffset(ticks.Value, TimeSpan.Zero) : (DateTimeOffset?)null); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/AccountId.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/AccountId.cs new file mode 100644 index 0000000..2554044 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/AccountId.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for an OneDrive account identifier. +public readonly record struct AccountId(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/DisplayName.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/DisplayName.cs new file mode 100644 index 0000000..99af889 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/DisplayName.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for a display name. +public readonly record struct DisplayName(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/DriveId.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/DriveId.cs new file mode 100644 index 0000000..467cc60 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/DriveId.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for an OneDrive drive identifier. +public readonly record struct DriveId(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/EmailAddress.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/EmailAddress.cs new file mode 100644 index 0000000..6c8d659 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/EmailAddress.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for an email address. +public readonly record struct EmailAddress(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/LocalPath.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/LocalPath.cs new file mode 100644 index 0000000..1853985 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/LocalPath.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for a local file system path. +public readonly record struct LocalPath(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/LocalSyncPath.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/LocalSyncPath.cs new file mode 100644 index 0000000..3990bc0 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/LocalSyncPath.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for a local sync root path. +public readonly record struct LocalSyncPath(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/OneDriveFolderId.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/OneDriveFolderId.cs new file mode 100644 index 0000000..2c116dc --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/OneDriveFolderId.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for an OneDrive folder identifier. +public readonly record struct OneDriveFolderId(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/OneDriveItemId.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/OneDriveItemId.cs new file mode 100644 index 0000000..a5bcb96 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/OneDriveItemId.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for an OneDrive item identifier. +public readonly record struct OneDriveItemId(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/RemotePath.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/RemotePath.cs new file mode 100644 index 0000000..e4db3c8 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/RemotePath.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for a remote OneDrive item path. +public readonly record struct RemotePath(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncConflictId.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncConflictId.cs new file mode 100644 index 0000000..f13d934 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncConflictId.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for a sync conflict identifier. +public readonly record struct SyncConflictId(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncJobId.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncJobId.cs new file mode 100644 index 0000000..0e072cb --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncJobId.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for a sync job identifier. +public readonly record struct SyncJobId(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncRuleId.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncRuleId.cs new file mode 100644 index 0000000..7fc6fb2 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncRuleId.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for a sync rule identifier. +public readonly record struct SyncRuleId(string Value); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncedItemId.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncedItemId.cs new file mode 100644 index 0000000..2436205 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/ValueObjects/SyncedItemId.cs @@ -0,0 +1,4 @@ +namespace AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; + +/// Strongly-typed wrapper for a synced item identifier. +public readonly record struct SyncedItemId(string Value); diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj new file mode 100644 index 0000000..cd8e7a2 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj @@ -0,0 +1,36 @@ + + + enable + enable + Exe + AStar.Dev.CloudSyncFunctional.Tests.Integration + net10.0 + true + true + NU1902;NU1903;CA1859 + false + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRuleRepository.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRuleRepository.cs new file mode 100644 index 0000000..c3a8a7e --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRuleRepository.cs @@ -0,0 +1,47 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.CloudSyncFunctional.Tests.Integration.TestData; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Integration.Repositories; + +public class GivenASyncRuleRepository(DatabaseFixture db) : IClassFixture +{ + private SyncRuleRepository CreateSut() => new(new TestDbContextFactory(db.Connection)); + + private static SyncRuleEntity CreateEntity(AccountId accountId) => + new() + { + Id = new SyncRuleId(Guid.NewGuid().ToString()), + AccountId = accountId, + RemotePath = "/Documents", + RuleType = RuleType.Include + }; + + [Fact] + public async Task when_a_sync_rule_is_upserted_then_result_is_ok() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + var entity = CreateEntity(accountId); + var sut = CreateSut(); + + var result = await sut.UpsertAsync(entity, CancellationToken.None); + + result.ShouldBeOfType>(); + } + + [Fact] + public async Task when_sync_rules_are_upserted_then_they_can_be_retrieved_by_account() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + var entity = CreateEntity(accountId); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + var result = await sut.GetByAccountAsync(accountId, CancellationToken.None); + + result.Count.ShouldBe(1); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenAnAccountRepository.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenAnAccountRepository.cs new file mode 100644 index 0000000..c301ba0 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenAnAccountRepository.cs @@ -0,0 +1,75 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.CloudSyncFunctional.Tests.Integration.TestData; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Integration.Repositories; + +public class GivenAnAccountRepository(DatabaseFixture db) : IClassFixture +{ + private AccountRepository CreateSut() => new(new TestDbContextFactory(db.Connection)); + + private static AccountEntity CreateEntity() => + new() + { + Id = new AccountId(Guid.NewGuid().ToString()), + Profile = new AccountProfileEntity + { + DisplayName = new DisplayName("Test User"), + Email = new EmailAddress("test@example.com") + }, + IsActive = true, + DriveId = new DriveId("drive-1"), + SyncConfig = new AccountSyncConfig { LocalSyncPath = new LocalSyncPath("/home/test/OneDrive"), WorkerCount = 4 } + }; + + [Fact] + public async Task when_an_account_is_upserted_then_result_is_ok() + { + var entity = CreateEntity(); + var sut = CreateSut(); + + var result = await sut.UpsertAsync(entity, CancellationToken.None); + + result.ShouldBeOfType>(); + } + + [Fact] + public async Task when_an_account_is_upserted_then_it_can_be_retrieved_by_id() + { + var entity = CreateEntity(); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + var result = await sut.GetByIdAsync(entity.Id, CancellationToken.None); + + result.ShouldBeOfType>(); + } + + [Fact] + public async Task when_a_non_existent_account_is_retrieved_then_result_is_none() + { + var sut = CreateSut(); + var missingId = new AccountId(Guid.NewGuid().ToString()); + + var result = await sut.GetByIdAsync(missingId, CancellationToken.None); + + result.ShouldBeOfType>(); + } + + [Fact] + public async Task when_an_account_is_deleted_then_get_by_id_returns_none() + { + var entity = CreateEntity(); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + await sut.DeleteAsync(entity.Id, CancellationToken.None); + var result = await sut.GetByIdAsync(entity.Id, CancellationToken.None); + + result.ShouldBeOfType>(); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/DatabaseFixture.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/DatabaseFixture.cs new file mode 100644 index 0000000..ea30566 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/DatabaseFixture.cs @@ -0,0 +1,28 @@ +using AStar.Dev.CloudSyncFunctional.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Integration.TestData; + +/// Provides a shared in-memory SQLite connection for an integration test class. +public sealed class DatabaseFixture : IAsyncLifetime +{ + /// Gets the shared open SQLite connection. + public SqliteConnection Connection { get; } = new("DataSource=:memory:"); + + /// + public async ValueTask InitializeAsync() + { + await Connection.OpenAsync(); + + var options = new DbContextOptionsBuilder().UseSqlite(Connection).Options; + await using var context = new AppDbContext(options); + + // EnsureCreated used here because no EF migrations exist yet (RED commit — stub only). + // Replace with MigrateAsync once the first migration is generated in the GREEN phase. + await context.Database.EnsureCreatedAsync(); + } + + /// + public async ValueTask DisposeAsync() => await Connection.DisposeAsync(); +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/TestDbContextFactory.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/TestDbContextFactory.cs new file mode 100644 index 0000000..2d23f12 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/TestDbContextFactory.cs @@ -0,0 +1,19 @@ +using AStar.Dev.CloudSyncFunctional.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Integration.TestData; + +/// A minimal implementation for integration tests. +internal sealed class TestDbContextFactory : IDbContextFactory +{ + private readonly DbContextOptions options; + + /// Initialises a new bound to the given connection. + /// The shared SQLite connection to use. + internal TestDbContextFactory(SqliteConnection connection) + => options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + + /// + public AppDbContext CreateDbContext() => new(options); +} From edf59fffbd1076a007b193a56a2c802f84f035f8 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 27 May 2026 11:42:49 +0100 Subject: [PATCH 2/2] feat(persistence): EF Core + SQLite persistence layer (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 13 strongly-typed domain value objects (AccountId, DriveId, EmailAddress, etc.) - Add 8 EF Core entities with IEntityTypeConfiguration, cascade deletes, and DateTimeOffset→ticks converters - Add AppDbContext with ApplyConfigurationsFromAssembly and InitialCreate migration - Add SqliteTypeConverters for all value objects and DateTimeOffset variants - Add 6 repository interfaces + implementations (Account, SyncRule, SyncedItem, DriveState, Sync, FileClassificationRule) - Add AppDbContextDesignTimeFactory for dotnet-ef tooling - Replace AccountOnboardingService in-memory stub with real IAccountRepository + ISyncRuleRepository persistence - Add BindAsync overload to ResultExtensions for async chaining without intermediate variables - Register IDbContextFactory (singleton) and all repositories (transient) in App.axaml.cs - Call Database.Migrate() on startup - Add integration test project with DatabaseFixture (MigrateAsync), 21 integration tests across Account, SyncRule, DriveState repositories and AccountOnboardingService Closes #26 Co-Authored-By: Claude Sonnet 4.6 --- .../App.axaml.cs | 35 +- .../Onboarding/AccountOnboardingService.cs | 83 ++++- .../AppDbContextDesignTimeFactory.cs | 18 + .../AccountEntityConfiguration.cs | 3 + .../DriveStateEntityConfiguration.cs | 5 + .../SyncConflictEntityConfiguration.cs | 6 + .../SyncJobEntityConfiguration.cs | 5 + .../SyncRuleEntityConfiguration.cs | 4 + ...edItemClassificationEntityConfiguration.cs | 4 + .../SyncedItemEntityConfiguration.cs | 5 + .../20260527085721_InitialCreate.Designer.cs | 308 ++++++++++++++++++ .../20260527085721_InitialCreate.cs | 225 +++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 305 +++++++++++++++++ .../Repositories/AccountRepository.cs | 65 +++- .../Repositories/DriveStateRepository.cs | 36 +- .../FileClassificationRuleRepository.cs | 54 ++- .../Repositories/SyncRepository.cs | 112 ++++++- .../Repositories/SyncRuleRepository.cs | 58 +++- .../Repositories/SyncedItemRepository.cs | 69 +++- .../Persistence/SqliteTypeConverters.cs | 4 + .../ResultExtensions.cs | 19 ++ ...oudSyncFunctional.Tests.Integration.csproj | 2 + ...enAnAccountOnboardingServiceIntegration.cs | 79 +++++ .../GivenADriveStateRepository.cs | 75 +++++ .../Repositories/GivenASyncRuleRepository.cs | 59 ++++ .../Repositories/GivenAnAccountRepository.cs | 63 ++++ .../TestData/DatabaseFixture.cs | 5 +- .../TestData/TestDbContextFactory.cs | 4 + .../GivenAnAccountOnboardingService.cs | 17 +- 29 files changed, 1674 insertions(+), 53 deletions(-) create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContextDesignTimeFactory.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.Designer.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.cs create mode 100644 src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/AppDbContextModelSnapshot.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Onboarding/GivenAnAccountOnboardingServiceIntegration.cs create mode 100644 test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenADriveStateRepository.cs diff --git a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs index 204757c..1fd4893 100644 --- a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs @@ -1,11 +1,14 @@ using AStar.Dev.CloudSyncFunctional.Auth; using AStar.Dev.CloudSyncFunctional.Graph; using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; using AStar.Dev.CloudSyncFunctional.Wizard; using AStar.Dev.CloudSyncFunctional.Workspace; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -34,6 +37,8 @@ public override void OnFrameworkInitializationCompleted() ConfigureServices(services, configuration); _serviceProvider = services.BuildServiceProvider(); + ApplyDatabaseMigrations(_serviceProvider); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) desktop.MainWindow = new MainWindow(_serviceProvider.GetRequiredService()); @@ -58,8 +63,36 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + + var connectionString = $"DataSource={GetDatabasePath()}"; + services.AddDbContextFactory(options => + options.UseSqlite(connectionString), ServiceLifetime.Singleton); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); services.AddTransient(); services.AddTransient(); } + + private static void ApplyDatabaseMigrations(IServiceProvider serviceProvider) + { + var dbContextFactory = serviceProvider.GetRequiredService>(); + using var startupContext = dbContextFactory.CreateDbContext(); + startupContext.Database.Migrate(); + } + + private static string GetDatabasePath() + { + var configDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var appDir = Path.Combine(configDir, "astar-dev-cloudsync"); + Directory.CreateDirectory(appDir); + + return Path.Combine(appDir, "sync.db"); + } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs index 06a543d..bd9d684 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs @@ -1,21 +1,96 @@ using AStar.Dev.CloudSyncFunctional.Domain; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; using AStar.Dev.FunctionalParadigm; using Microsoft.Extensions.Logging; namespace AStar.Dev.CloudSyncFunctional.Onboarding; /// -public sealed partial class AccountOnboardingService(ILogger logger) : IAccountOnboardingService +public sealed partial class AccountOnboardingService(IAccountRepository accountRepository, ISyncRuleRepository syncRuleRepository, ILogger logger) : IAccountOnboardingService { + private static readonly char[] InvalidPathChars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|', + ..Enumerable.Range(0, 32).Select(i => (char)i)]; + /// - public Task> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default) + public async Task> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default) { account.IsActive = true; - LogOnboardingComplete(logger, account.AccountId); + var entity = MapToEntity(account); + + return await accountRepository.UpsertAsync(entity, ct) + .BindAsync(_ => UpsertSyncRulesAsync(account, ct)) + .MatchAsync>( + _ => + { + LogOnboardingComplete(logger, account.AccountId); + return new Ok(account); + }, + error => + { + LogOnboardingFailed(logger, account.AccountId, error.Message); + return new Fail(error); + }); + } + + private async Task> UpsertSyncRulesAsync(OneDriveAccount account, CancellationToken ct) + { + foreach (var folderId in account.SelectedFolderIds) + { + var rule = new SyncRuleEntity + { + Id = new SyncRuleId(Guid.NewGuid().ToString()), + AccountId = new AccountId(account.AccountId), + RemotePath = folderId, + RuleType = RuleType.Include + }; + + var stopResult = await syncRuleRepository.UpsertAsync(rule, ct) + .MatchAsync?>( + _ => null, + error => new Fail(error)) + .ConfigureAwait(false); + + if (stopResult is not null) + return stopResult; + } - return Task.FromResult>(new Ok(account)); + return new Ok(Unit.Default); } + private static AccountEntity MapToEntity(OneDriveAccount account) => + new() + { + Id = new AccountId(account.AccountId), + Profile = new AccountProfileEntity + { + DisplayName = new DisplayName(account.Profile.DisplayName), + Email = new EmailAddress(account.Profile.Email) + }, + IsActive = account.IsActive, + DriveId = new DriveId(account.DriveId ?? string.Empty), + SyncConfig = new AccountSyncConfig + { + LocalSyncPath = new LocalSyncPath(ComputeDefaultSyncPath(account.Profile.Email)), + WorkerCount = 8 + } + }; + + private static string ComputeDefaultSyncPath(string email) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var sanitised = SanitiseEmail(email); + + return Path.Combine(home, "OneDrive", sanitised); + } + + private static string SanitiseEmail(string email) => + string.Concat(email.Where(c => !InvalidPathChars.Contains(c))); + [LoggerMessage(Level = LogLevel.Information, Message = "Account onboarding completed for {AccountId}")] private static partial void LogOnboardingComplete(ILogger logger, string accountId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Account onboarding failed for {AccountId}: {ErrorMessage}")] + private static partial void LogOnboardingFailed(ILogger logger, string accountId, string errorMessage); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContextDesignTimeFactory.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContextDesignTimeFactory.cs new file mode 100644 index 0000000..3ccc6f5 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContextDesignTimeFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace AStar.Dev.CloudSyncFunctional.Persistence; + +/// Design-time factory used by EF Core tooling to create instances. +public sealed class AppDbContextDesignTimeFactory : IDesignTimeDbContextFactory +{ + /// + public AppDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=design-time.db") + .Options; + + return new AppDbContext(options); + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs index cc49604..74295ec 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs @@ -13,6 +13,9 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.AccountIdConverter); builder.Property(e => e.DriveId).HasConversion(SqliteTypeConverters.DriveIdConverter); + builder.Property(e => e.LastSyncedAt) + .HasConversion(SqliteTypeConverters.NullableDateTimeOffsetToTicks) + .IsRequired(false); builder.ComplexProperty(e => e.Profile, p => { p.Property(prof => prof.DisplayName).HasConversion(SqliteTypeConverters.DisplayNameConverter).HasColumnName("DisplayName"); diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs index c58fd1e..393f804 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs @@ -12,5 +12,10 @@ public void Configure(EntityTypeBuilder builder) { builder.HasKey(e => e.AccountId); builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + builder.Property(e => e.LastCheckedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks); + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs index 872cd79..0a6039b 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs @@ -13,5 +13,11 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncConflictIdConverter); builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + builder.Property(e => e.LocalModifiedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks); + builder.Property(e => e.RemoteModifiedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks); + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs index 6ace8f3..936d3f9 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs @@ -13,5 +13,10 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncJobIdConverter); builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + builder.Property(e => e.CreatedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks); + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs index e21a084..0c4c579 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs @@ -13,5 +13,9 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncRuleIdConverter); builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs index 8139af3..2b544ae 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs @@ -12,5 +12,9 @@ public void Configure(EntityTypeBuilder builder) { builder.HasKey(e => e.Id); builder.Property(e => e.SyncedItemId).HasConversion(SqliteTypeConverters.SyncedItemIdConverter); + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.SyncedItemId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs index 05ee639..3b59ef9 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs @@ -13,5 +13,10 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncedItemIdConverter); builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter); + builder.Property(e => e.RemoteModifiedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks); + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.AccountId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.Designer.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.Designer.cs new file mode 100644 index 0000000..e367598 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.Designer.cs @@ -0,0 +1,308 @@ +// +using System.Collections.Generic; +using AStar.Dev.CloudSyncFunctional.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260527085721_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DriveId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncedAt") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Profile", "AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity.Profile#AccountProfileEntity", b1 => + { + b1.IsRequired(); + + b1.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("DisplayName"); + + b1.Property("Email") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Email"); + }); + + b.ComplexProperty(typeof(Dictionary), "SyncConfig", "AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity.SyncConfig#AccountSyncConfig", b1 => + { + b1.IsRequired(); + + b1.Property("LocalSyncPath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("LocalSyncPath"); + + b1.Property("WorkerCount") + .HasColumnType("INTEGER") + .HasColumnName("WorkerCount"); + }); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.DriveStateEntity", b => + { + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("DeltaLink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("INTEGER"); + + b.HasKey("AccountId"); + + b.ToTable("DriveStates"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.FileClassificationRuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Classification") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Keywords") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("FileClassificationRules"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncConflictEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("RemoteItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemoteModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("State") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("SyncConflicts"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncJobEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("JobType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("SyncJobs"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncRuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RuleType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("SyncRules"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemClassificationEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Classification") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SyncedItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SyncedItemId"); + + b.ToTable("SyncedItemClassifications"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ETag") + .HasColumnType("TEXT"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemoteModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("SyncedItems"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.DriveStateEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncConflictEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncJobEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncRuleEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemClassificationEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", null) + .WithMany() + .HasForeignKey("SyncedItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.cs new file mode 100644 index 0000000..f4795ff --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.cs @@ -0,0 +1,225 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Accounts", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + DriveId = table.Column(type: "TEXT", nullable: false), + LastSyncedAt = table.Column(type: "INTEGER", nullable: true), + DisplayName = table.Column(type: "TEXT", nullable: false), + Email = table.Column(type: "TEXT", nullable: false), + LocalSyncPath = table.Column(type: "TEXT", nullable: false), + WorkerCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Accounts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FileClassificationRules", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Classification = table.Column(type: "TEXT", nullable: false), + Keywords = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FileClassificationRules", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DriveStates", + columns: table => new + { + AccountId = table.Column(type: "TEXT", nullable: false), + DeltaLink = table.Column(type: "TEXT", nullable: false), + LastCheckedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DriveStates", x => x.AccountId); + table.ForeignKey( + name: "FK_DriveStates_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SyncConflicts", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AccountId = table.Column(type: "TEXT", nullable: false), + RemoteItemId = table.Column(type: "TEXT", nullable: false), + LocalModifiedAt = table.Column(type: "INTEGER", nullable: false), + RemoteModifiedAt = table.Column(type: "INTEGER", nullable: false), + State = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncConflicts", x => x.Id); + table.ForeignKey( + name: "FK_SyncConflicts_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SyncedItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AccountId = table.Column(type: "TEXT", nullable: false), + RemotePath = table.Column(type: "TEXT", nullable: false), + LocalPath = table.Column(type: "TEXT", nullable: false), + RemoteModifiedAt = table.Column(type: "INTEGER", nullable: false), + ETag = table.Column(type: "TEXT", nullable: true), + IsFolder = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncedItems", x => x.Id); + table.ForeignKey( + name: "FK_SyncedItems_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SyncJobs", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AccountId = table.Column(type: "TEXT", nullable: false), + RemotePath = table.Column(type: "TEXT", nullable: false), + LocalPath = table.Column(type: "TEXT", nullable: false), + JobType = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncJobs", x => x.Id); + table.ForeignKey( + name: "FK_SyncJobs_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SyncRules", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AccountId = table.Column(type: "TEXT", nullable: false), + RemotePath = table.Column(type: "TEXT", nullable: false), + RuleType = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncRules", x => x.Id); + table.ForeignKey( + name: "FK_SyncRules_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SyncedItemClassifications", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SyncedItemId = table.Column(type: "TEXT", nullable: false), + Classification = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncedItemClassifications", x => x.Id); + table.ForeignKey( + name: "FK_SyncedItemClassifications_SyncedItems_SyncedItemId", + column: x => x.SyncedItemId, + principalTable: "SyncedItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SyncConflicts_AccountId", + table: "SyncConflicts", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_SyncedItemClassifications_SyncedItemId", + table: "SyncedItemClassifications", + column: "SyncedItemId"); + + migrationBuilder.CreateIndex( + name: "IX_SyncedItems_AccountId", + table: "SyncedItems", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_SyncJobs_AccountId", + table: "SyncJobs", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_SyncRules_AccountId", + table: "SyncRules", + column: "AccountId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DriveStates"); + + migrationBuilder.DropTable( + name: "FileClassificationRules"); + + migrationBuilder.DropTable( + name: "SyncConflicts"); + + migrationBuilder.DropTable( + name: "SyncedItemClassifications"); + + migrationBuilder.DropTable( + name: "SyncJobs"); + + migrationBuilder.DropTable( + name: "SyncRules"); + + migrationBuilder.DropTable( + name: "SyncedItems"); + + migrationBuilder.DropTable( + name: "Accounts"); + } + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..d5304e1 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,305 @@ +// +using System.Collections.Generic; +using AStar.Dev.CloudSyncFunctional.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AStar.Dev.CloudSyncFunctional.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DriveId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncedAt") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Profile", "AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity.Profile#AccountProfileEntity", b1 => + { + b1.IsRequired(); + + b1.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("DisplayName"); + + b1.Property("Email") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Email"); + }); + + b.ComplexProperty(typeof(Dictionary), "SyncConfig", "AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity.SyncConfig#AccountSyncConfig", b1 => + { + b1.IsRequired(); + + b1.Property("LocalSyncPath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("LocalSyncPath"); + + b1.Property("WorkerCount") + .HasColumnType("INTEGER") + .HasColumnName("WorkerCount"); + }); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.DriveStateEntity", b => + { + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("DeltaLink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("INTEGER"); + + b.HasKey("AccountId"); + + b.ToTable("DriveStates"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.FileClassificationRuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Classification") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Keywords") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("FileClassificationRules"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncConflictEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("RemoteItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemoteModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("State") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("SyncConflicts"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncJobEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("JobType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("SyncJobs"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncRuleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RuleType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("SyncRules"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemClassificationEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Classification") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SyncedItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SyncedItemId"); + + b.ToTable("SyncedItemClassifications"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ETag") + .HasColumnType("TEXT"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemoteModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("SyncedItems"); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.DriveStateEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncConflictEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncJobEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncRuleEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemClassificationEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", null) + .WithMany() + .HasForeignKey("SyncedItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", b => + { + b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs index 35a142c..de3b0f7 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs @@ -10,18 +10,67 @@ namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; public sealed class AccountRepository(IDbContextFactory dbFactory) : IAccountRepository { /// - public Task> GetByIdAsync(AccountId id, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> GetByIdAsync(AccountId id, CancellationToken ct = default) + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var entity = await context.Accounts.FindAsync([id], ct).ConfigureAwait(false); + + return entity is null + ? new None(PersistenceErrorFactory.Unexpected("Account not found.")) + : new Some(entity); + } /// - public Task> GetAllAsync(CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + + return await context.Accounts.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); + } /// - public Task> UpsertAsync(AccountEntity entity, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> UpsertAsync(AccountEntity entity, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.Accounts.FindAsync([entity.Id], ct).ConfigureAwait(false); + if (existing is null) + context.Accounts.Add(entity); + else + context.Entry(existing).CurrentValues.SetValues(entity); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + + return new Ok(Unit.Default); + } + catch (DbUpdateConcurrencyException) + { + return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } /// - public Task> DeleteAsync(AccountId id, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> DeleteAsync(AccountId id, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.Accounts.FindAsync([id], ct).ConfigureAwait(false); + if (existing is not null) + { + context.Accounts.Remove(existing); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + } + + return new Ok(Unit.Default); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs index 4e2aa0f..623e01c 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/DriveStateRepository.cs @@ -10,10 +10,38 @@ namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; public sealed class DriveStateRepository(IDbContextFactory dbFactory) : IDriveStateRepository { /// - public Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var entity = await context.DriveStates.FindAsync([accountId], ct).ConfigureAwait(false); + + return entity is null + ? new None(PersistenceErrorFactory.Unexpected("Drive state not found.")) + : new Some(entity); + } /// - public Task> UpsertAsync(DriveStateEntity entity, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> UpsertAsync(DriveStateEntity entity, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.DriveStates.FindAsync([entity.AccountId], ct).ConfigureAwait(false); + if (existing is null) + context.DriveStates.Add(entity); + else + context.Entry(existing).CurrentValues.SetValues(entity); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + + return new Ok(Unit.Default); + } + catch (DbUpdateConcurrencyException) + { + return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs index b5113c5..8e98096 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/FileClassificationRuleRepository.cs @@ -9,14 +9,56 @@ namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; public sealed class FileClassificationRuleRepository(IDbContextFactory dbFactory) : IFileClassificationRuleRepository { /// - public Task> GetAllAsync(CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + + return await context.FileClassificationRules.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); + } /// - public Task> UpsertAsync(FileClassificationRuleEntity entity, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> UpsertAsync(FileClassificationRuleEntity entity, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.FileClassificationRules.FindAsync([entity.Id], ct).ConfigureAwait(false); + if (existing is null) + context.FileClassificationRules.Add(entity); + else + context.Entry(existing).CurrentValues.SetValues(entity); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + + return new Ok(Unit.Default); + } + catch (DbUpdateConcurrencyException) + { + return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } /// - public Task> DeleteAsync(string id, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> DeleteAsync(string id, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.FileClassificationRules.FindAsync([id], ct).ConfigureAwait(false); + if (existing is not null) + { + context.FileClassificationRules.Remove(existing); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + } + + return new Ok(Unit.Default); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs index 037aa9e..10a827f 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRepository.cs @@ -9,23 +9,115 @@ namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; /// public sealed class SyncRepository(IDbContextFactory dbFactory) : ISyncRepository { + private const string PendingState = "Pending"; + private const string ResolvedState = "Resolved"; + private const string CompletedStatus = "Completed"; + /// - public Task> GetPendingConflictsAsync(AccountId accountId, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> GetPendingConflictsAsync(AccountId accountId, CancellationToken ct = default) + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + + return await context.SyncConflicts + .AsNoTracking() + .Where(c => c.AccountId == accountId && c.State == PendingState) + .ToListAsync(ct) + .ConfigureAwait(false); + } /// - public Task> UpsertConflictAsync(SyncConflictEntity entity, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> UpsertConflictAsync(SyncConflictEntity entity, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.SyncConflicts.FindAsync([entity.Id], ct).ConfigureAwait(false); + if (existing is null) + context.SyncConflicts.Add(entity); + else + context.Entry(existing).CurrentValues.SetValues(entity); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + + return new Ok(Unit.Default); + } + catch (DbUpdateConcurrencyException) + { + return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } /// - public Task> ResolveConflictAsync(SyncConflictId id, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> ResolveConflictAsync(SyncConflictId id, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.SyncConflicts.FindAsync([id], ct).ConfigureAwait(false); + if (existing is not null) + { + existing.State = ResolvedState; + await context.SaveChangesAsync(ct).ConfigureAwait(false); + } + + return new Ok(Unit.Default); + } + catch (DbUpdateConcurrencyException) + { + return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } /// - public Task> UpsertJobAsync(SyncJobEntity entity, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> UpsertJobAsync(SyncJobEntity entity, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.SyncJobs.FindAsync([entity.Id], ct).ConfigureAwait(false); + if (existing is null) + context.SyncJobs.Add(entity); + else + context.Entry(existing).CurrentValues.SetValues(entity); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + + return new Ok(Unit.Default); + } + catch (DbUpdateConcurrencyException) + { + return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } /// - public Task> ClearCompletedJobsAsync(AccountId accountId, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> ClearCompletedJobsAsync(AccountId accountId, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var completed = await context.SyncJobs + .Where(j => j.AccountId == accountId && j.Status == CompletedStatus) + .ToListAsync(ct) + .ConfigureAwait(false); + context.SyncJobs.RemoveRange(completed); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + + return new Ok(Unit.Default); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs index 1b9190c..77a858d 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncRuleRepository.cs @@ -10,14 +10,60 @@ namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; public sealed class SyncRuleRepository(IDbContextFactory dbFactory) : ISyncRuleRepository { /// - public Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + + return await context.SyncRules + .AsNoTracking() + .Where(r => r.AccountId == accountId) + .ToListAsync(ct) + .ConfigureAwait(false); + } /// - public Task> UpsertAsync(SyncRuleEntity entity, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> UpsertAsync(SyncRuleEntity entity, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.SyncRules.FindAsync([entity.Id], ct).ConfigureAwait(false); + if (existing is null) + context.SyncRules.Add(entity); + else + context.Entry(existing).CurrentValues.SetValues(entity); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + + return new Ok(Unit.Default); + } + catch (DbUpdateConcurrencyException) + { + return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } /// - public Task> DeleteAsync(SyncRuleId id, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> DeleteAsync(SyncRuleId id, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.SyncRules.FindAsync([id], ct).ConfigureAwait(false); + if (existing is not null) + { + context.SyncRules.Remove(existing); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + } + + return new Ok(Unit.Default); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs index 210c9cb..7e92af2 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/SyncedItemRepository.cs @@ -10,18 +10,71 @@ namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories; public sealed class SyncedItemRepository(IDbContextFactory dbFactory) : ISyncedItemRepository { /// - public Task> GetByIdAsync(SyncedItemId id, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> GetByIdAsync(SyncedItemId id, CancellationToken ct = default) + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var entity = await context.SyncedItems.FindAsync([id], ct).ConfigureAwait(false); + + return entity is null + ? new None(PersistenceErrorFactory.Unexpected("Synced item not found.")) + : new Some(entity); + } /// - public Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> GetByAccountAsync(AccountId accountId, CancellationToken ct = default) + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + + return await context.SyncedItems + .AsNoTracking() + .Where(i => i.AccountId == accountId) + .ToListAsync(ct) + .ConfigureAwait(false); + } /// - public Task> UpsertAsync(SyncedItemEntity entity, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> UpsertAsync(SyncedItemEntity entity, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.SyncedItems.FindAsync([entity.Id], ct).ConfigureAwait(false); + if (existing is null) + context.SyncedItems.Add(entity); + else + context.Entry(existing).CurrentValues.SetValues(entity); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + + return new Ok(Unit.Default); + } + catch (DbUpdateConcurrencyException) + { + return new Fail(PersistenceErrorFactory.ConcurrencyConflict()); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } /// - public Task> DeleteAsync(SyncedItemId id, CancellationToken ct = default) - => throw new NotImplementedException("Not yet implemented"); + public async Task> DeleteAsync(SyncedItemId id, CancellationToken ct = default) + { + try + { + await using var context = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var existing = await context.SyncedItems.FindAsync([id], ct).ConfigureAwait(false); + if (existing is not null) + { + context.SyncedItems.Remove(existing); + await context.SaveChangesAsync(ct).ConfigureAwait(false); + } + + return new Ok(Unit.Default); + } + catch (DbUpdateException ex) + { + return new Fail(PersistenceErrorFactory.Unexpected(ex.Message)); + } + } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/SqliteTypeConverters.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/SqliteTypeConverters.cs index b19547f..6e8a736 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Persistence/SqliteTypeConverters.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/SqliteTypeConverters.cs @@ -58,6 +58,10 @@ public static class SqliteTypeConverters public static ValueConverter RemotePathConverter { get; } = new(p => p.Value, str => new RemotePath(str)); + /// Converts a non-nullable to and from UTC ticks. + public static ValueConverter DateTimeOffsetToTicks { get; } = + new(dt => dt.UtcTicks, ticks => new DateTimeOffset(ticks, TimeSpan.Zero)); + /// Converts a nullable to and from a nullable UTC ticks . public static ValueConverter NullableDateTimeOffsetToTicks { get; } = new(dt => dt.HasValue ? dt.Value.UtcTicks : (long?)null, diff --git a/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs b/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs index 4681b8e..bb12145 100644 --- a/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs +++ b/src/AStar.Dev.FunctionalParadigm/ResultExtensions.cs @@ -114,4 +114,23 @@ public static async Task MatchAsync(this TaskAsynchronously chains an operation that also returns a result, propagating failure. + /// The input success type. + /// The output success type. + /// The error type. + /// The task producing the result to bind. + /// The async chained operation to invoke on success. + /// The result of the chained operation, or the original failure propagated as the new error type. + public static async Task> BindAsync(this Task> taskResult, Func>> binder) + { + var result = await taskResult.ConfigureAwait(false); + + return result switch + { + Ok ok => await binder(ok.Value).ConfigureAwait(false), + Fail fail => new Fail(fail.Error), + _ => throw new InvalidOperationException("Unexpected result type.") + }; + } } diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj index cd8e7a2..ccf05ec 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj @@ -14,6 +14,7 @@ + @@ -21,6 +22,7 @@ + diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Onboarding/GivenAnAccountOnboardingServiceIntegration.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Onboarding/GivenAnAccountOnboardingServiceIntegration.cs new file mode 100644 index 0000000..41f5c59 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Onboarding/GivenAnAccountOnboardingServiceIntegration.cs @@ -0,0 +1,79 @@ +using AStar.Dev.CloudSyncFunctional.Auth; +using AStar.Dev.CloudSyncFunctional.Domain; +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.CloudSyncFunctional.Tests.Integration.TestData; +using AStar.Dev.FunctionalParadigm; +using Microsoft.Extensions.Logging; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Integration.Onboarding; + +public class GivenAnAccountOnboardingServiceIntegration(DatabaseFixture db) : IClassFixture +{ + private AccountOnboardingService CreateSut() => + new(new AccountRepository(new TestDbContextFactory(db.Connection)), + new SyncRuleRepository(new TestDbContextFactory(db.Connection)), + Substitute.For>()); + + private SyncRuleRepository CreateSyncRuleRepo() => + new(new TestDbContextFactory(db.Connection)); + + private AccountRepository CreateAccountRepo() => + new(new TestDbContextFactory(db.Connection)); + + private static OneDriveAccount CreateAccount(params string[] folderIds) => + new() + { + AccountId = Guid.NewGuid().ToString(), + Profile = new AccountProfile("Test User", "test@example.com"), + SelectedFolderIds = folderIds + }; + + [Fact] + public async Task when_complete_onboarding_is_called_then_result_is_ok() + { + var account = CreateAccount("folder-1", "folder-2"); + var sut = CreateSut(); + + var result = await sut.CompleteOnboardingAsync(account, CancellationToken.None); + + result.ShouldBeOfType>(); + } + + [Fact] + public async Task when_complete_onboarding_is_called_then_account_is_persisted() + { + var account = CreateAccount("folder-1"); + var sut = CreateSut(); + + await sut.CompleteOnboardingAsync(account, CancellationToken.None); + var stored = await CreateAccountRepo().GetByIdAsync(new AccountId(account.AccountId), CancellationToken.None); + + stored.ShouldBeOfType>(); + } + + [Fact] + public async Task when_complete_onboarding_is_called_then_sync_rules_are_persisted_for_each_folder() + { + var account = CreateAccount("folder-1", "folder-2"); + var sut = CreateSut(); + + await sut.CompleteOnboardingAsync(account, CancellationToken.None); + var rules = await CreateSyncRuleRepo().GetByAccountAsync(new AccountId(account.AccountId), CancellationToken.None); + + rules.Count.ShouldBe(2); + } + + [Fact] + public async Task when_complete_onboarding_is_called_with_no_folders_then_no_sync_rules_are_created() + { + var account = CreateAccount(); + var sut = CreateSut(); + + await sut.CompleteOnboardingAsync(account, CancellationToken.None); + var rules = await CreateSyncRuleRepo().GetByAccountAsync(new AccountId(account.AccountId), CancellationToken.None); + + rules.ShouldBeEmpty(); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenADriveStateRepository.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenADriveStateRepository.cs new file mode 100644 index 0000000..9648f63 --- /dev/null +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenADriveStateRepository.cs @@ -0,0 +1,75 @@ +using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; +using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects; +using AStar.Dev.CloudSyncFunctional.Tests.Integration.TestData; +using AStar.Dev.FunctionalParadigm; + +namespace AStar.Dev.CloudSyncFunctional.Tests.Integration.Repositories; + +public class GivenADriveStateRepository(DatabaseFixture db) : IClassFixture +{ + private DriveStateRepository CreateSut() => new(new TestDbContextFactory(db.Connection)); + private AccountRepository CreateAccountSut() => new(new TestDbContextFactory(db.Connection)); + + private static AccountEntity CreateAccountEntity(AccountId accountId) => + new() + { + Id = accountId, + Profile = new AccountProfileEntity + { + DisplayName = new DisplayName("Test User"), + Email = new EmailAddress("test@example.com") + }, + IsActive = true, + DriveId = new DriveId("drive-1"), + SyncConfig = new AccountSyncConfig { LocalSyncPath = new LocalSyncPath("/home/test/OneDrive"), WorkerCount = 4 } + }; + + private static DriveStateEntity CreateDriveStateEntity(AccountId accountId) => + new() + { + AccountId = accountId, + DeltaLink = "https://graph.microsoft.com/v1.0/drives/xxx/root/delta?token=abc", + LastCheckedAt = DateTimeOffset.UtcNow.AddHours(-1) + }; + + [Fact] + public async Task when_a_drive_state_is_upserted_then_result_is_ok() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var entity = CreateDriveStateEntity(accountId); + var sut = CreateSut(); + + var result = await sut.UpsertAsync(entity, CancellationToken.None); + + result.ShouldBeOfType>(); + } + + [Fact] + public async Task when_a_drive_state_is_retrieved_then_it_matches_what_was_stored() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var entity = CreateDriveStateEntity(accountId); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + var result = await sut.GetByAccountAsync(accountId, CancellationToken.None); + + var some = (Some)result; + some.Value.DeltaLink.ShouldBe(entity.DeltaLink); + } + + [Fact] + public async Task when_drive_state_for_non_existent_account_is_retrieved_then_result_is_none() + { + var missingId = new AccountId(Guid.NewGuid().ToString()); + var sut = CreateSut(); + + var result = await sut.GetByAccountAsync(missingId, CancellationToken.None); + + result.ShouldBeOfType>(); + } +} diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRuleRepository.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRuleRepository.cs index c3a8a7e..fc8ce80 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRuleRepository.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenASyncRuleRepository.cs @@ -10,6 +10,21 @@ namespace AStar.Dev.CloudSyncFunctional.Tests.Integration.Repositories; public class GivenASyncRuleRepository(DatabaseFixture db) : IClassFixture { private SyncRuleRepository CreateSut() => new(new TestDbContextFactory(db.Connection)); + private AccountRepository CreateAccountSut() => new(new TestDbContextFactory(db.Connection)); + + private static AccountEntity CreateAccountEntity(AccountId accountId) => + new() + { + Id = accountId, + Profile = new AccountProfileEntity + { + DisplayName = new DisplayName("Test User"), + Email = new EmailAddress("test@example.com") + }, + IsActive = true, + DriveId = new DriveId("drive-1"), + SyncConfig = new AccountSyncConfig { LocalSyncPath = new LocalSyncPath("/home/test/OneDrive"), WorkerCount = 4 } + }; private static SyncRuleEntity CreateEntity(AccountId accountId) => new() @@ -24,6 +39,7 @@ private static SyncRuleEntity CreateEntity(AccountId accountId) => public async Task when_a_sync_rule_is_upserted_then_result_is_ok() { var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); var entity = CreateEntity(accountId); var sut = CreateSut(); @@ -36,6 +52,7 @@ public async Task when_a_sync_rule_is_upserted_then_result_is_ok() public async Task when_sync_rules_are_upserted_then_they_can_be_retrieved_by_account() { var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); var entity = CreateEntity(accountId); var sut = CreateSut(); @@ -44,4 +61,46 @@ public async Task when_sync_rules_are_upserted_then_they_can_be_retrieved_by_acc result.Count.ShouldBe(1); } + + [Fact] + public async Task when_sync_rules_for_different_accounts_do_not_overlap() + { + var accountIdA = new AccountId(Guid.NewGuid().ToString()); + var accountIdB = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountIdA), CancellationToken.None); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountIdB), CancellationToken.None); + var sut = CreateSut(); + + await sut.UpsertAsync(CreateEntity(accountIdA), CancellationToken.None); + await sut.UpsertAsync(CreateEntity(accountIdB), CancellationToken.None); + var rulesForA = await sut.GetByAccountAsync(accountIdA, CancellationToken.None); + + rulesForA.ShouldAllBe(r => r.AccountId == accountIdA); + } + + [Fact] + public async Task when_a_sync_rule_is_deleted_then_it_cannot_be_retrieved() + { + var accountId = new AccountId(Guid.NewGuid().ToString()); + await CreateAccountSut().UpsertAsync(CreateAccountEntity(accountId), CancellationToken.None); + var entity = CreateEntity(accountId); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + await sut.DeleteAsync(entity.Id, CancellationToken.None); + var result = await sut.GetByAccountAsync(accountId, CancellationToken.None); + + result.ShouldBeEmpty(); + } + + [Fact] + public async Task when_deleting_a_non_existent_sync_rule_then_result_is_ok() + { + var missingId = new SyncRuleId(Guid.NewGuid().ToString()); + var sut = CreateSut(); + + var result = await sut.DeleteAsync(missingId, CancellationToken.None); + + result.ShouldBeOfType>(); + } } diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenAnAccountRepository.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenAnAccountRepository.cs index c301ba0..9e64613 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenAnAccountRepository.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/Repositories/GivenAnAccountRepository.cs @@ -72,4 +72,67 @@ public async Task when_an_account_is_deleted_then_get_by_id_returns_none() result.ShouldBeOfType>(); } + + [Fact] + public async Task when_get_all_is_called_then_result_includes_upserted_account() + { + var entity = CreateEntity(); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + var result = await sut.GetAllAsync(CancellationToken.None); + + result.ShouldContain(a => a.Id == entity.Id); + } + + [Fact] + public async Task when_an_account_is_upserted_twice_then_it_is_updated_not_duplicated() + { + var entity = CreateEntity(); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + entity.IsActive = false; + await sut.UpsertAsync(entity, CancellationToken.None); + var all = await sut.GetAllAsync(CancellationToken.None); + + all.Count(a => a.Id == entity.Id).ShouldBe(1); + } + + [Fact] + public async Task when_an_account_is_retrieved_then_profile_email_is_preserved() + { + var entity = CreateEntity(); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + var result = await sut.GetByIdAsync(entity.Id, CancellationToken.None); + + var some = (Some)result; + some.Value.Profile.Email.Value.ShouldBe("test@example.com"); + } + + [Fact] + public async Task when_an_account_is_retrieved_then_sync_config_worker_count_is_preserved() + { + var entity = CreateEntity(); + var sut = CreateSut(); + + await sut.UpsertAsync(entity, CancellationToken.None); + var result = await sut.GetByIdAsync(entity.Id, CancellationToken.None); + + var some = (Some)result; + some.Value.SyncConfig.WorkerCount.ShouldBe(4); + } + + [Fact] + public async Task when_deleting_a_non_existent_account_then_result_is_ok() + { + var missingId = new AccountId(Guid.NewGuid().ToString()); + var sut = CreateSut(); + + var result = await sut.DeleteAsync(missingId, CancellationToken.None); + + result.ShouldBeOfType>(); + } } diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/DatabaseFixture.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/DatabaseFixture.cs index ea30566..551a90b 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/DatabaseFixture.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/DatabaseFixture.cs @@ -17,10 +17,7 @@ public async ValueTask InitializeAsync() var options = new DbContextOptionsBuilder().UseSqlite(Connection).Options; await using var context = new AppDbContext(options); - - // EnsureCreated used here because no EF migrations exist yet (RED commit — stub only). - // Replace with MigrateAsync once the first migration is generated in the GREEN phase. - await context.Database.EnsureCreatedAsync(); + await context.Database.MigrateAsync(); } /// diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/TestDbContextFactory.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/TestDbContextFactory.cs index 2d23f12..30b4a07 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/TestDbContextFactory.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Integration/TestData/TestDbContextFactory.cs @@ -16,4 +16,8 @@ internal TestDbContextFactory(SqliteConnection connection) /// public AppDbContext CreateDbContext() => new(options); + + /// + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AppDbContext(options)); } diff --git a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs index 7eb0498..e453317 100644 --- a/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs +++ b/test/AStar.Dev.CloudSyncFunctional.Tests.Unit/Onboarding/GivenAnAccountOnboardingService.cs @@ -1,15 +1,28 @@ using AStar.Dev.CloudSyncFunctional.Auth; using AStar.Dev.CloudSyncFunctional.Domain; using AStar.Dev.CloudSyncFunctional.Onboarding; +using AStar.Dev.CloudSyncFunctional.Persistence.Entities; +using AStar.Dev.CloudSyncFunctional.Persistence.Repositories; using AStar.Dev.FunctionalParadigm; using Microsoft.Extensions.Logging; +using FpUnit = AStar.Dev.FunctionalParadigm.Unit; namespace AStar.Dev.CloudSyncFunctional.Tests.Unit.Onboarding; public class GivenAnAccountOnboardingService { - private static AccountOnboardingService CreateSut() => - new(Substitute.For>()); + private static AccountOnboardingService CreateSut() + { + var accountRepo = Substitute.For(); + accountRepo.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new Ok(FpUnit.Default))); + + var syncRuleRepo = Substitute.For(); + syncRuleRepo.UpsertAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new Ok(FpUnit.Default))); + + return new AccountOnboardingService(accountRepo, syncRuleRepo, Substitute.For>()); + } private static OneDriveAccount CreateAccount() => new()