Finbuckle / Finbuckle.MultiTenant

Finbuckle.MultiTenant is an open-source multitenancy middleware library for .NET. It enables tenant resolution, per-tenant app behavior, and per-tenant data isolation.
https://www.finbuckle.com/multitenant
Apache License 2.0
1.26k stars 256 forks source link

TenantInfo being Null in integration test under WebApplicationFactory #804

Closed asaleh-lab closed 2 months ago

asaleh-lab commented 3 months ago

Hi,

when I query a table with MuiltiTenant feature I get NullReferenceException

Query

 public async Task<Unit?> GetByIdAsync(UnitId id, CancellationToken cancellationToken = default)
    {
        return await _dbContext.Units
            .FirstOrDefaultAsync(unit => unit.Id == id, cancellationToken);
    }

On this object

using Server.Domain.Units;
using Finbuckle.MultiTenant.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Server.Infrastructure.Configurations;

internal class UnitConfiguration : IEntityTypeConfiguration<Unit>
{
    public void Configure(EntityTypeBuilder<Unit> builder)
    {
        builder.ToTable("lookup_units");
        builder.HasKey(u => u.Id);
        builder.Property(u => u.Id)
            .ValueGeneratedNever()
            .HasConversion(
                id => id.Value,
                value => UnitId.Create(value));
        builder.HasIndex(u => u.Name).IsUnique();
        builder.HasQueryFilter(q => !q.IsDeleted);
        builder.IsMultiTenant().AdjustUniqueIndexes();

        builder.Property(u => u.Name).HasMaxLength(64);
        builder.Property(u => u.DefaultContentCount);
        builder.HasData(new List<Unit>()
        {
            Unit.Create(
                UnitId.Create(Guid.Parse("F7AE4D5C-B1B7-47F0-B0A0-D98DAC374FE8")),
                "bag", null, "tenant-1")
        });
    }
}

Circumstances:

Configuration To minimize the dependencies and isolate the problem I've configured MultiTenant as the following:

        services.AddMultiTenant<TenantInfo>()
            .WithStaticStrategy("tenant-1")
            .WithInMemoryStore(config=>
                config.Tenants.Add(new TenantInfo
                {
                    Id = "tenant-1",Identifier = "tenant-1", Name = "tenant-1",
                }))
            ;

I appreciate your advice what to change or where to inspect further Thanks!

AndrewTriesToCode commented 3 months ago

Hi, can you post your integration test with web application factory and how it is all setup?

asaleh-lab commented 3 months ago

Hi, can you post your integration test with web application factory and how it is all setup?

Sure!

this is my base calss, it doesn't have anything related to MultiTenent as I've changed the store and the strategy to InMemory & Static... I'll post the program.cs as well

using Server.Api;
using Server.Application.Abstractions.Data;
using Server.Infrastructure;
using Server.Infrastructure.Data;
using Server.Infrastructure.Interceptors;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;

namespace Server.Application.IntegrationTests.Infrastructure;

public class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithImage("postgres:latest")
        .WithDatabase("server")
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();

    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
    }

    public new async Task DisposeAsync()
    {
        await _dbContainer.StopAsync();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            string connectionString = SetApplicationDbContext(services);

            services.RemoveAll(typeof(ISqlConnectionFactory));

            services.AddSingleton<ISqlConnectionFactory>(_ =>
                new SqlConnectionFactory(connectionString));

        });
    }

    private string SetApplicationDbContext(IServiceCollection services)
    {
        services.RemoveAll(typeof(DbContextOptions<ApplicationDbContext>));

        string connectionString = $"{_dbContainer.GetConnectionString()};Pooling=False";

        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseNpgsql(connectionString).UseSnakeCaseNamingConvention()
                .AddInterceptors(new SoftDeleteInterceptor()));
        return connectionString;
    }

}

One driven calss

using Server.Application.IntegrationTests.Infrastructure;
using Server.Application.Listings.AddListing;
using Server.Domain.Abstractions;
using FluentAssertions;

namespace Server.Application.IntegrationTests.Listings;

public class AddListingTests: BaseIntegrationTest
{
    public AddListingTests(IntegrationTestWebAppFactory factory) 
        : base(factory)
    {

    }

    [Fact]
    public async Task AddListing_ShouldReturnSuccess_WhenListingIsCreated()
    {
        // Arrange
        AddListingCommand command = ListingData.CreateAddListingCommand();

        // Act
        Result result = await Sender.Send(command);

        // Assert
        result.IsSuccess.Should().BeTrue();
    }
}

Program.cs

using Asp.Versioning.ApiExplorer;
using Server.Api;
using Server.Api.Extensions;
using Server.Api.OpenApi;
using Server.Application;
using Server.Application.Listings.AddListing;
using Server.Domain.Abstractions;
using Server.Infrastructure;
using Finbuckle.MultiTenant;
using HealthChecks.UI.Client;
using MediatR;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Serilog;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Host.UseSerilog((context, loggerConfig) =>
    loggerConfig.ReadFrom.Configuration(context.Configuration));

builder.Services.AddControllers();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// builder.Services.AddMultiTenant<TenantInfo>()
#pragma warning disable S125
//     .WithStaticStrategy("tenant-1");
#pragma warning restore S125

builder.Services.AddApplication();

builder.Services.AddInfrastructure(builder.Configuration); //MultiTenant is configured here

builder.Services.ConfigureOptions<ConfigureSwaggerOptions>();

builder.Services.AddAutoMapperMaps();

WebApplication app = builder.Build();

app.UseMultiTenant();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        foreach (ApiVersionDescription description in app.DescribeApiVersions())
        {
            string url = $"/swagger/{description.GroupName}/swagger.json";
            string name = description.GroupName.ToUpperInvariant();
            options.SwaggerEndpoint(url, name);
        }
    });
}

app.UseHttpsRedirection();

app.UseRequestContextLogging();

app.UseSerilogRequestLogging();

app.UseCustomExceptionHandler();

app.UseAuthentication();

app.UseAuthorization();

app.MapControllers();

app.MapHealthChecks("health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.MapGet("/api/tenant", (TenantInfo tenantInfo) => Results.Ok(new
{
    tenantInfo.Id, tenantInfo.Name, tenantInfo.Identifier, tenantInfo.ConnectionString
}));

app.MapGet("/api/test", async (ISender sender) =>
{
    AddListingCommand command = new(
        new Name("Pasta", "ar-EG"),
        new Barcode("1234567890"),
        new ListingUnit(Guid.Parse("F7AE4D5C-B1B7-47F0-B0A0-D98DAC374FE8"),
            new Barcode("1234567890"),
            10, "EGP",
            20, "EGP",
            30, "EGP",
            40, "EGP",
            50, "EGP")
    );
    Result<AddListingCommandResponse> result = await sender.Send(command, CancellationToken.None);
    return result.IsSuccess ? Results.Ok(result) : Results.BadRequest(result.Error);
});

await app.RunAsync();

namespace Server.Api
{
    public class Program;
}

Infrastructure including MultiTenant configuration


using Server.Application.Abstractions.Clock;
using Server.Application.Abstractions.Data;
using Server.Application.Abstractions.Email;
using Server.Domain.Abstractions;
using Server.Domain.Listings;
using Server.Domain.Units;
using Server.Domain.Users;
using Server.Infrastructure.Clock;
using Server.Infrastructure.Data;
using Server.Infrastructure.Email;
using Server.Infrastructure.Interceptors;
using Server.Infrastructure.Repositories;
using Dapper;
using Finbuckle.MultiTenant;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Server.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddTransient<IDateTimeProvider, DateTimeProvider>();

        services.AddTransient<IEmailService, EmailService>();

        AddMultiTenant(services);

        AddPersistence(services, configuration);

        return services;
    }

    private static void AddPersistence(IServiceCollection services, IConfiguration configuration)
    {
        string connectionString = configuration.GetConnectionString("Database") ??
                                  throw new ArgumentNullException(nameof(configuration));

        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseNpgsql(connectionString).UseSnakeCaseNamingConvention()
                .AddInterceptors(new SoftDeleteInterceptor()));

        services.AddScoped<IUserRepository, UserRepository>();

        services.AddScoped<IListingRepository, ListingRepository>();

        services.AddScoped<IUnitRepository, UnitRepository>();

        services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<ApplicationDbContext>());

        services.AddSingleton<ISqlConnectionFactory>(_ =>
            new SqlConnectionFactory(connectionString));

        SqlMapper.AddTypeHandler(new DateOnlyTypeHandler());
    }

    private static void AddMultiTenant(IServiceCollection services)
    {
            services.AddMultiTenant<TenantInfo>()
                .WithStaticStrategy("tenant-1")
                .WithInMemoryStore(config=>
                    config.Tenants.Add(new TenantInfo
                    {
                        Id = "tenant-1",Identifier = "tenant-1", Name = "tenant-1",
                    }))
                ;
    }
}

Please let me know if you need further info Thanks!

AndrewTriesToCode commented 2 months ago

Hm I don’t see anything obvious. If you put a breakpoint in the multitenant middleware can you tell if the tenant is being correctly resolved at that point?

asaleh-lab commented 2 months ago

Only the breakpoint in constructor is being hit but unfortunately it doesn't hit the invoke method at all, it jumps after the constructor directly to the call in following method where i get the NullPointerException

public async Task<Unit?> GetByIdAsync(UnitId id, CancellationToken cancellationToken = default)
    {
        return await _dbContext.Units
            .FirstOrDefaultAsync(unit => unit.Id == id, cancellationToken);
    }

image

AndrewTriesToCode commented 2 months ago

Is there anything initiating a query to the db context before your controller is hit? Can you tell if the exception is occurring before the /api/test delegate is entered?

asaleh-lab commented 2 months ago

Hi, sorry.... It was my mistake. I test the calling of the APIs in another functional test, while the integration test starts from command/query down to the repositories which means there was no requests at all and accordingly the middleware was not triggered
Adding a singleton of type ITenantinfo satisfies the AppDbContext and i'm not geeting the NullReferenceExcption anymore

            services.Where(x => x.ToString().Contains("tenant", StringComparison.CurrentCultureIgnoreCase))
                .ToList()
                .ForEach(x => services.Remove(x));

            var tenantInfo = new TenantInfo
            {
                Id = "tenant-1", Identifier = "tenant-1", Name = "tenant-1",
            };
            services.AddMultiTenant<TenantInfo>()
                .WithStaticStrategy("tenant-1")
                .WithInMemoryStore(config =>
                    config.Tenants.Add(tenantInfo)
                );

            services.AddSingleton<ITenantInfo>(tenantInfo);          

Thanks and have a nice day!

AndrewTriesToCode commented 2 months ago

Ok I’m glad you got it working!