diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd31997..c803ab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: run: sudo apt-get install -y libsqlite3-mod-spatialite - name: Restore dependencies - run: dotnet restore + run: dotnet restore -p:TargetFrameworks=${{ env.DOTNET_FX_VERSION }} - name: Build run: dotnet build --no-restore -c Release -f ${{ env.DOTNET_FX_VERSION }} diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index e624ca7..40488a5 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -35,7 +35,7 @@ jobs: run: sudo apt-get install -y libsqlite3-mod-spatialite - name: Restore dependencies - run: dotnet restore + run: dotnet restore -p:TargetFrameworks=${{ env.DOTNET_FX_VERSION }} - name: Build run: dotnet build --no-restore --version-suffix $GITHUB_RUN_ID -c Release -f ${{ env.DOTNET_FX_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f45c399..e164414 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,13 +49,13 @@ jobs: # run: dotnet nuget add source "https://nuget.pkg.github.com/deveel/index.json" -n "Deveel GitHub" -u ${{ secrets.DEVEEL_NUGET_USER }} -p ${{ secrets.DEVEEL_NUGET_TOKEN }} --store-password-in-clear-text - name: Restore dependencies - run: dotnet restore + run: dotnet restore -p:TargetFrameworks=${{ env.DOTNET_FX_VERSION }} - name: Build - run: dotnet build --no-restore -c Release + run: dotnet build --no-restore -c Release -f ${{ env.DOTNET_FX_VERSION }} - name: Test - run: dotnet test --no-build --verbosity normal -c Release /p:CollectCoverage=true /p:CoverletOutputFormat=opencover + run: dotnet test --no-build --verbosity normal -c Release /p:CollectCoverage=true /p:CoverletOutputFormat=opencover -f ${{ env.DOTNET_FX_VERSION }} - name: Collect to Codecov uses: codecov/codecov-action@v3 diff --git a/src/Deveel.Repository.Core/Data/IHaveOwner.cs b/src/Deveel.Repository.Core/Data/IHaveOwner.cs new file mode 100644 index 0000000..1749341 --- /dev/null +++ b/src/Deveel.Repository.Core/Data/IHaveOwner.cs @@ -0,0 +1,24 @@ +namespace Deveel.Data +{ + /// + /// The contract used to define an object that + /// has an owner. + /// + public interface IHaveOwner + { + /// + /// Gets the identifier of the owner of + /// the object. + /// + TKey Owner { get; } + + /// + /// Sets the owner of the object, eventually + /// overriding the current owner. + /// + /// + /// The identifier of the new owner. + /// + void SetOwner(TKey owner); + } +} diff --git a/src/Deveel.Repository.Core/Data/IUserAccessor.cs b/src/Deveel.Repository.Core/Data/IUserAccessor.cs new file mode 100644 index 0000000..ef4c0cb --- /dev/null +++ b/src/Deveel.Repository.Core/Data/IUserAccessor.cs @@ -0,0 +1,26 @@ +namespace Deveel.Data +{ + /// + /// A service that provides information about the current user + /// of the application. + /// + /// + /// This contact can be used to retrieve identifier about the + /// user that is currently using the application, such as the + /// username or the user ID. + /// + /// + /// The type of the key that identifies the user. + /// + public interface IUserAccessor + { + /// + /// Gets the identifier of the current user. + /// + /// + /// Returns a string that represents the identifier of the + /// user that is currently using the application. + /// + TKey? GetUserId(); + } +} diff --git a/src/Deveel.Repository.Core/Data/IUserRepository.cs b/src/Deveel.Repository.Core/Data/IUserRepository.cs new file mode 100644 index 0000000..a3c554f --- /dev/null +++ b/src/Deveel.Repository.Core/Data/IUserRepository.cs @@ -0,0 +1,24 @@ +namespace Deveel.Data +{ + /// + /// Defines a repository that is bound to a user context, + /// where the entities are owned by a specific user. + /// + /// + /// The type of the entity handled by the repository. + /// + /// + /// The type of the unique identifier of the entity. + /// + /// + /// The type of the key that identifies the owner of the entity. + /// + public interface IUserRepository : IRepository + where TEntity : class, IHaveOwner + { + /// + /// Gets the accessor to the user context of the repository. + /// + IUserAccessor UserAccessor { get; } + } +} diff --git a/src/Deveel.Repository.EntityFramework/Data/DataOwnerAttribute.cs b/src/Deveel.Repository.EntityFramework/Data/DataOwnerAttribute.cs new file mode 100644 index 0000000..5fa775a --- /dev/null +++ b/src/Deveel.Repository.EntityFramework/Data/DataOwnerAttribute.cs @@ -0,0 +1,7 @@ +namespace Deveel.Data +{ + [System.AttributeUsage(System.AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] + public sealed class DataOwnerAttribute : Attribute + { + } +} diff --git a/src/Deveel.Repository.EntityFramework/Data/EntityRepository_T2.cs b/src/Deveel.Repository.EntityFramework/Data/EntityRepository_T2.cs index 830885d..d894b96 100644 --- a/src/Deveel.Repository.EntityFramework/Data/EntityRepository_T2.cs +++ b/src/Deveel.Repository.EntityFramework/Data/EntityRepository_T2.cs @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Globalization; - using Finbuckle.MultiTenant; using Finbuckle.MultiTenant.EntityFrameworkCore; @@ -23,7 +21,10 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Deveel.Data { +using System.Globalization; + +namespace Deveel.Data +{ /// /// A repository that uses an to access the data /// of the entities. @@ -208,6 +209,21 @@ protected void ThrowIfDisposed() { return (TKey?) getter.GetClrValue(entity); } + /// + /// A method that is invoked when an entity is + /// being added to the repository. + /// + /// + /// The entity that is being added to the repository. + /// + /// + /// Returns the entity that will be added to the repository. + /// + protected virtual TEntity OnAddingEntity(TEntity entity) + { + return entity; + } + /// public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) { ThrowIfDisposed(); @@ -217,7 +233,8 @@ public virtual async Task AddAsync(TEntity entity, CancellationToken cancellatio Logger.TraceCreatingEntity(typeof(TEntity), TenantId); try { - Entities.Add(entity); + Entities.Add(OnAddingEntity(entity)); + var count = await Context.SaveChangesAsync(cancellationToken); if (count > 1) { @@ -238,7 +255,9 @@ public virtual async Task AddRangeAsync(IEnumerable entities, Cancellat ThrowIfDisposed(); try { - await Entities.AddRangeAsync(entities, cancellationToken); + var toAdd = entities.Select(OnAddingEntity).ToList(); + + await Entities.AddRangeAsync(toAdd, cancellationToken); var count = await Context.SaveChangesAsync(true, cancellationToken); } catch (Exception ex) { diff --git a/src/Deveel.Repository.EntityFramework/Data/EntityTypeBuilderExtensions.cs b/src/Deveel.Repository.EntityFramework/Data/EntityTypeBuilderExtensions.cs new file mode 100644 index 0000000..a8aaa05 --- /dev/null +++ b/src/Deveel.Repository.EntityFramework/Data/EntityTypeBuilderExtensions.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using System.Linq.Expressions; + +namespace Deveel.Data +{ + /// + /// Extensions for the class + /// to provide additional configuration for entities that have an owner. + /// + public static class EntityTypeBuilderExtensions + { + /// + /// Configures the entity to have a query filter that restricts the + /// data to the owner of the entity. + /// + /// + /// The type of the entity that has an owner. + /// + /// + /// The type of the key that identifies the user. + /// + /// + /// A builder to configure the entity. + /// + /// + /// A service that provides information about the current user + /// that is using the application. + /// + /// + /// Returns the builder to continue the configuration. + /// + /// + public static EntityTypeBuilder HasOwnerFilter(this EntityTypeBuilder builder, IUserAccessor userAccessor) + where TEntity : class, IHaveOwner + => builder.HasOwnerFilter("", userAccessor); + + /// + /// Configures the entity to have a query filter that restricts the + /// data to the owner of the entity. + /// + /// + /// The type of the entity that has an owner. + /// + /// + /// The type of the key that identifies the user. + /// + /// + /// A builder to configure the entity. + /// + /// + /// The name of the property that holds the owner identifier. + /// + /// + /// A service that provides information about the current user + /// that is using the application. + /// + /// + /// Returns the builder to continue the configuration. + /// + /// + /// Throws when the property name is not found in the entity type. + /// + public static EntityTypeBuilder HasOwnerFilter(this EntityTypeBuilder builder, string propertyName, IUserAccessor userAccessor) + where TEntity : class, IHaveOwner + { + if (string.IsNullOrWhiteSpace(propertyName)) + { + foreach(var property in builder.Metadata.GetDeclaredProperties()) + { + if (Attribute.IsDefined(property.PropertyInfo, typeof(DataOwnerAttribute))) + { + propertyName = property.Name; + break; + } + } + } else { + var fieldMetadata = builder.Metadata.FindDeclaredProperty(propertyName); + if (fieldMetadata == null) + throw new RepositoryException($"The property '{propertyName}' was not found in the entity type '{typeof(TEntity).Name}'"); + } + + if (string.IsNullOrWhiteSpace(propertyName)) + throw new RepositoryException($"The property name was not specified and no property was found in the entity type '{typeof(TEntity).Name}'"); + + var varRef = Expression.Variable(typeof(TEntity), "x"); + var propertyRef = Expression.Property(varRef, propertyName); + var getUserId = Expression.Call( + Expression.Constant(userAccessor), + typeof(IUserAccessor) + .GetMethod(nameof(IUserAccessor.GetUserId))); + + LambdaExpression lambda; + + if (typeof(TUserKey) == typeof(string)) + { + lambda = Expression.Lambda(Expression.Equal(propertyRef, getUserId), varRef); + + } else + { + var equalsMethod = typeof(TUserKey).GetMethod(nameof(object.Equals), new[] { typeof(object) }); + lambda = Expression.Lambda(Expression.Call(propertyRef, equalsMethod, getUserId), varRef); + } + + return builder.HasQueryFilter(lambda); + } + } +} diff --git a/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T.cs b/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T.cs new file mode 100644 index 0000000..7aa8e30 --- /dev/null +++ b/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T.cs @@ -0,0 +1,55 @@ +using Finbuckle.MultiTenant; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Deveel.Data +{ + /// + /// An implementation of a repository that is bound to a specific user + /// context, where the entities are owned by a specific user. + /// + /// + /// The type of the entity managed by the repository. + /// + /// + public class EntityUserRepository : EntityUserRepository + where TEntity : class, IHaveOwner + { + /// + /// Constructs the repository using the given . + /// + /// + /// The used to access the data of the entities. + /// + /// + /// A service used to get the current user context. + /// + /// + /// A logger used to log the operations of the repository. + /// + public EntityUserRepository(DbContext context, IUserAccessor userAccessor, ILogger>? logger = null) : base(context, userAccessor, logger) + { + } + + /// + /// Constructs the repository using the given for + /// a specific tenant. + /// + /// + /// The used to access the data of the entities. + /// + /// + /// The information about the tenant that the repository will use to access the data. + /// + /// + /// A service used to get the current user context. + /// + /// + /// A logger used to log the operations of the repository. + /// + public EntityUserRepository(DbContext context, ITenantInfo? tenantInfo, IUserAccessor userAccessor, ILogger>? logger = null) : base(context, tenantInfo, userAccessor, logger) + { + } + } +} diff --git a/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T2.cs b/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T2.cs new file mode 100644 index 0000000..89ed765 --- /dev/null +++ b/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T2.cs @@ -0,0 +1,63 @@ + +using Finbuckle.MultiTenant; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Deveel.Data +{ + /// + /// An implementation of a repository that is bound to a specific user + /// that owns the entities. + /// + /// + /// The type of the entity managed by the repository. + /// + /// + /// The type of the key that identifies the owner of the entity. + /// + /// + /// This version of the repository is assuming the type of the key of the entity + /// of the repository is . + /// + /// + public class EntityUserRepository : EntityUserRepository + where TEntity : class, IHaveOwner + { + /// + /// Constructs the repository using the given . + /// + /// + /// The used to access the data of the entities. + /// + /// + /// A service used to get the current user context. + /// + /// + /// A logger used to log the operations of the repository. + /// + public EntityUserRepository(DbContext context, IUserAccessor userAccessor, ILogger>? logger = null) : base(context, userAccessor, logger) + { + } + + /// + /// Constructs the repository using the given for + /// the given tenant. + /// + /// + /// The used to access the data of the entities. + /// + /// + /// The information about the tenant that the repository will use to access the data. + /// + /// + /// A service used to get the current user context. + /// + /// + /// A logger used to log the operations of the repository. + /// + public EntityUserRepository(DbContext context, ITenantInfo? tenantInfo, IUserAccessor userAccessor, ILogger>? logger = null) : base(context, tenantInfo, userAccessor, logger) + { + } + } +} diff --git a/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T3.cs b/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T3.cs new file mode 100644 index 0000000..17115af --- /dev/null +++ b/src/Deveel.Repository.EntityFramework/Data/EntityUserRepository_T3.cs @@ -0,0 +1,100 @@ +using Finbuckle.MultiTenant; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Deveel.Data +{ + /// + /// An implementation of a repository that is bound to a specific user + /// context, where the entities are owned by a specific user. + /// + /// + /// The type of the entity managed by the repository. + /// + /// + /// The type of the unique identifier of the entity. + /// + /// + /// The type of the key that identifies the owner of the entity. + /// + public class EntityUserRepository : EntityRepository, IUserRepository + where TEntity : class, IHaveOwner + { + /// + /// Constructs the repository using the given . + /// + /// + /// The used to access the data of the entities. + /// + /// + /// A service used to get the current user context. + /// + /// + /// A logger used to log the operations of the repository. + /// + /// + /// Thrown when the given is null. + /// + public EntityUserRepository(DbContext context, IUserAccessor userAccessor, ILogger>? logger = null) : base(context, logger) + { + ArgumentNullException.ThrowIfNull(userAccessor, nameof(userAccessor)); + UserAccessor = userAccessor; + } + + /// + /// Constructs the repository using the given for + /// the given tenant. + /// + /// + /// The used to access the data of the entities. + /// + /// + /// The information about the tenant that the repository will use to access the data. + /// + /// + /// A service used to get the current user context. + /// + /// + /// A logger used to log the operations of the repository. + /// + public EntityUserRepository(DbContext context, ITenantInfo? tenantInfo, IUserAccessor userAccessor, ILogger>? logger = null) : base(context, tenantInfo, logger) + { + ArgumentNullException.ThrowIfNull(userAccessor, nameof(userAccessor)); + UserAccessor = userAccessor; + } + + IUserAccessor IUserRepository.UserAccessor => UserAccessor; + + /// + /// Gets the accessor to the user context of the repository. + /// + protected IUserAccessor UserAccessor { get; } + + /// + /// Gets the identifier of the current user. + /// + protected TUserKey? UserId => UserAccessor.GetUserId(); + + /// + public override async Task FindAsync(TKey key, CancellationToken cancellationToken = default) + { + var result = await base.FindAsync(key, cancellationToken); + + if (result == null || !Equals(UserId, result.Owner)) + return null; + return result; + } + + /// + protected override TEntity OnAddingEntity(TEntity entity) + { + var userId = UserId; + if (!Equals(default(TUserKey), UserId) + && !Equals(entity.Owner, userId)) + entity.SetOwner(userId!); + + return entity; + } + } +} diff --git a/src/Deveel.Repository.Manager/Data/OperationErrorFactory.cs b/src/Deveel.Repository.Manager/Data/OperationErrorFactory.cs index c751fea..74a092d 100644 --- a/src/Deveel.Repository.Manager/Data/OperationErrorFactory.cs +++ b/src/Deveel.Repository.Manager/Data/OperationErrorFactory.cs @@ -26,6 +26,9 @@ public class OperationErrorFactory : IOperationErrorFactory { /// /// The error code to normalize. /// + /// + /// The domain where the error belongs to. + /// /// /// The aim of this method is to provide a way to map /// a given error code to a normalized format, so that diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/BookDbContext.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/BookDbContext.cs new file mode 100644 index 0000000..d9d8888 --- /dev/null +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/BookDbContext.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; + +using System; + +namespace Deveel.Data +{ + public class BookDbContext : DbContext + { + public BookDbContext(DbContextOptions options, IUserAccessor userAccessor) : base(options) + { + this.userAccessor = userAccessor; + } + + private readonly IUserAccessor userAccessor; + + public DbSet Books { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(x => x.Id); + + modelBuilder.Entity() + .HasOwnerFilter(nameof(DbBook.UserId), userAccessor); + } + } +} diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBook.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBook.cs new file mode 100644 index 0000000..eb500d1 --- /dev/null +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBook.cs @@ -0,0 +1,24 @@ +#pragma warning disable CS8618 + +namespace Deveel.Data +{ + public class DbBook : IBook, IHaveOwner + { + public Guid Id { get; set; } + + public string Title { get; set; } + + public string? Synopsis { get; set; } + + public string Author { get; set; } + + public string UserId { get; set; } + + string IHaveOwner.Owner => UserId; + + void IHaveOwner.SetOwner(string owner) + { + UserId = owner; + } + } +} diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBookFaker.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBookFaker.cs new file mode 100644 index 0000000..ece26ef --- /dev/null +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBookFaker.cs @@ -0,0 +1,15 @@ +using Bogus; + +namespace Deveel.Data +{ + public class DbBookFaker : Faker + { + public DbBookFaker(string userId) + { + RuleFor(x => x.Title, f => f.Lorem.Sentence()); + RuleFor(x => x.Author, f => f.Name.FullName()); + RuleFor(x => x.Synopsis, f => f.Lorem.Paragraph()); + RuleFor(x => x.UserId, f => f.Random.Bool() ? userId : f.Random.Guid().ToString()); + } + } +} diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBookRepository.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBookRepository.cs new file mode 100644 index 0000000..5c2dbad --- /dev/null +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbBookRepository.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Deveel.Data +{ + public class DbBookRepository : EntityUserRepository + { + public DbBookRepository(DbContext context, IUserAccessor userAccessor, ILogger? logger = null) + : base(context, userAccessor, logger) + { + } + } +} diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryTestSuite.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryTestSuite.cs index 18b597a..a153a90 100644 --- a/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryTestSuite.cs +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryTestSuite.cs @@ -41,7 +41,7 @@ protected override Task RemoveRelationshipAsync(DbPerson person, DbRelationship } protected override void ConfigureServices(IServiceCollection services) { - services.AddDbContext(builder => { + services.AddDbContext(builder => { builder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); builder.UseSqlite(sql.Connection, sqlite => { sqlite.UseNetTopologySuite(); diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/SqlUserConnectionCollection.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/SqlUserConnectionCollection.cs new file mode 100644 index 0000000..be413fc --- /dev/null +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/SqlUserConnectionCollection.cs @@ -0,0 +1,7 @@ +namespace Deveel.Data +{ + [CollectionDefinition(nameof(SqlUserConnectionCollection))] + public class SqlUserConnectionCollection : SqlConnectionCollection + { + } +} diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/UserEntityRepositoryTestSuite.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/UserEntityRepositoryTestSuite.cs new file mode 100644 index 0000000..7e5d416 --- /dev/null +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/UserEntityRepositoryTestSuite.cs @@ -0,0 +1,81 @@ +using Bogus; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using Xunit.Abstractions; + +namespace Deveel.Data +{ + [Collection(nameof(SqlUserConnectionCollection))] + public class UserEntityRepositoryTestSuite : UserRepositoryTestSuite + { + private readonly SqlTestConnection sql; + + public UserEntityRepositoryTestSuite(ITestOutputHelper? outputHelper, SqlTestConnection sql) + : base(outputHelper) + { + this.sql = sql; + BookFaker = new DbBookFaker(UserId); + } + + protected override Faker BookFaker { get; } + + protected override Guid GenerateBookId() => Guid.NewGuid(); + + protected override string GenerateUserId() => Guid.NewGuid().ToString("N"); + + protected string ConnectionString => sql.Connection.ConnectionString; + + protected override void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(builder => { + builder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + builder.UseSqlite(sql.Connection, sqlite => { + sqlite.UseNetTopologySuite(); + }); + }) + .AddRepository(); + + base.ConfigureServices(services); + } + + protected override async Task InitializeAsync() + { + var userAccessor = Services.GetRequiredService>(); + var options = Services.GetRequiredService>(); + using var dbContext = new BookDbContext(options, userAccessor); + + await dbContext.Database.EnsureDeletedAsync(); + await dbContext.Database.EnsureCreatedAsync(); + + await base.InitializeAsync(); + } + + protected override async Task SeedAsync() + { + var userAccessor = Services.GetRequiredService>(); + var options = Services.GetRequiredService>(); + using var dbContext = new BookDbContext(options, userAccessor); + + if (Books != null) + { + await dbContext.Books.AddRangeAsync(Books); + await dbContext.SaveChangesAsync(true); + } + } + + protected override async Task DisposeAsync() + { + var userAccessor = Services.GetRequiredService>(); + var options = Services.GetRequiredService>(); + using var dbContext = new BookDbContext(options, userAccessor); + + dbContext.Books!.RemoveRange(dbContext.Books); + await dbContext.SaveChangesAsync(true); + + await dbContext.Database.EnsureDeletedAsync(); + } + + } +} diff --git a/test/Deveel.Repository.Tests.XUnit/Data/IBook.cs b/test/Deveel.Repository.Tests.XUnit/Data/IBook.cs new file mode 100644 index 0000000..d42c8a7 --- /dev/null +++ b/test/Deveel.Repository.Tests.XUnit/Data/IBook.cs @@ -0,0 +1,13 @@ +namespace Deveel.Data +{ + public interface IBook + { + TKey? Id { get; set; } + + string Title { get; set; } + + string? Synopsis { get; set; } + + string Author { get; set; } + } +} diff --git a/test/Deveel.Repository.Tests.XUnit/Data/UserRepositoryTestSuite_T3.cs b/test/Deveel.Repository.Tests.XUnit/Data/UserRepositoryTestSuite_T3.cs new file mode 100644 index 0000000..297f81a --- /dev/null +++ b/test/Deveel.Repository.Tests.XUnit/Data/UserRepositoryTestSuite_T3.cs @@ -0,0 +1,233 @@ +using Bogus; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using System.Collections.Immutable; +using System.Linq.Expressions; + +using Xunit.Abstractions; + +namespace Deveel.Data +{ + [Trait("Feature", "OwnerFilter")] + public abstract class UserRepositoryTestSuite : IAsyncLifetime, IAsyncDisposable + where TBook : class, IBook, IHaveOwner + where TKey : notnull + { + private IServiceProvider? services; + private AsyncServiceScope scope; + + protected UserRepositoryTestSuite(ITestOutputHelper? outputHelper) + { + TestOutput = outputHelper; + UserId = GenerateUserId(); + } + + protected ITestOutputHelper? TestOutput { get; } + + protected TUserKey UserId { get; } + + protected virtual int EntitySetCount => 100; + + protected IReadOnlyList? Books { get; private set; } = new List(); + + protected IServiceProvider Services => scope.ServiceProvider; + + protected virtual IRepository Repository => Services.GetRequiredService>(); + + protected abstract Faker BookFaker { get; } + + protected TBook GenerateBook() => BookFaker.Generate(); + + protected TBook GenerateUserBook() + { + var book = BookFaker.Generate(); + book.SetOwner(UserId); + return book; + } + + protected IList GenerateBooks(int count) => BookFaker.Generate(count); + + protected ISystemTime TestTime { get; } = new TestTime(); + + protected abstract TUserKey GenerateUserId(); + + protected abstract TKey GenerateBookId(); + + protected virtual void ConfigureServices(IServiceCollection services) + { + if (TestOutput != null) + services.AddLogging(logging => { logging.ClearProviders(); logging.AddXUnit(TestOutput); }); + + services.AddSingleton>(new StaticUserAccessor(this)); + } + + private void BuildServices() + { + var services = new ServiceCollection(); + services.AddSystemTime(TestTime); + + ConfigureServices(services); + + this.services = services.BuildServiceProvider(); + scope = this.services.CreateAsyncScope(); + } + + async Task IAsyncLifetime.InitializeAsync() + { + BuildServices(); + + Books = GenerateBooks(EntitySetCount).ToImmutableList(); + + await InitializeAsync(); + } + + protected virtual async Task InitializeAsync() + { + await SeedAsync(); + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + Books = null; + + await scope.DisposeAsync(); + (services as IDisposable)?.Dispose(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await DisposeAsync(); + } + + protected virtual Task DisposeAsync() + { + return Task.CompletedTask; + } + + protected virtual async Task SeedAsync() + { + if (Books != null) + await Repository.AddRangeAsync(Books); + } + + protected virtual Task FindBookAsync(object id) + { + var entity = Books?.FirstOrDefault(x => Repository.GetEntityKey(x)?.Equals(id) ?? false); + return Task.FromResult(entity); + } + + protected virtual Task RandomBookAsync(Expression>? predicate = null) + { + var result = Books?.Random(predicate?.Compile()); + + if (result == null) + throw new InvalidOperationException("No book found"); + + return Task.FromResult(result); + } + + [Fact] + public async Task AddNewUserBook() + { + var book = GenerateUserBook(); + + await Repository.AddAsync(book); + + var id = Repository.GetEntityKey(book); + + Assert.NotNull(id); + + var found = await Repository.FindAsync(id); + Assert.NotNull(found); + Assert.Equal(book.Title, found.Title); + Assert.Equal(book.Author, found.Author); + Assert.Equal(book.Synopsis, found.Synopsis); + } + + [Fact] + public async Task AddNewBook() + { + var book = GenerateBook(); + + await Repository.AddAsync(book); + + var id = Repository.GetEntityKey(book); + + Assert.NotNull(id); + + var found = await Repository.FindAsync(id); + Assert.NotNull(found); + Assert.Equal(book.Title, found.Title); + Assert.Equal(book.Author, found.Author); + Assert.Equal(book.Synopsis, found.Synopsis); + } + + [Fact] + public async Task FindBookById() + { + var book = await RandomBookAsync(x => Equals(x.Owner, UserId)); + + var found = await Repository.FindAsync(book.Id!); + + Assert.NotNull(found); + Assert.Equal(UserId, found.Owner); + Assert.Equal(book.Title, found.Title); + Assert.Equal(book.Author, found.Author); + Assert.Equal(book.Synopsis, found.Synopsis); + } + + [Fact] + public async Task FindBookById_NotFound() + { + var id = GenerateBookId(); + + var found = await Repository.FindAsync(id); + + Assert.Null(found); + } + + [Fact] + public async Task FindFirstBookById_OtherUser() + { + var book = await RandomBookAsync(x => !Equals(x.Owner, UserId)); + + var found = await Repository.FindFirstAsync(x => x.Id.Equals(book.Id)); + + Assert.Null(found); + + var bookInDb = await FindBookAsync(book.Id!); + Assert.NotNull(bookInDb); + Assert.NotEqual(UserId, bookInDb.Owner); + } + + [Fact] + public async Task FindBookById_OtherUser() + { + var book = await RandomBookAsync(x => !Equals(x.Owner, UserId)); + + var found = await Repository.FindAsync(book.Id!); + + Assert.Null(found); + + var bookInDb = await FindBookAsync(book.Id!); + Assert.NotNull(bookInDb); + Assert.NotEqual(UserId, bookInDb.Owner); + } + + + + class StaticUserAccessor : IUserAccessor + { + private readonly UserRepositoryTestSuite suite; + + public StaticUserAccessor(UserRepositoryTestSuite suite) + { + this.suite = suite; + } + + public TUserKey GetUserId() => suite.UserId; + } + } +}