diff --git a/core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj b/core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj index 2d4dea6..8811a8e 100644 --- a/core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj +++ b/core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj @@ -37,6 +37,23 @@ + + + + + MicroZenOAuth2 + + + + diff --git a/core/csharp/MicroZen.Api.Test/Services/ClientsGrpcServiceGetManyClientsTests.cs b/core/csharp/MicroZen.Api.Test/Services/ClientsGrpcServiceGetManyClientsTests.cs new file mode 100644 index 0000000..067bdbe --- /dev/null +++ b/core/csharp/MicroZen.Api.Test/Services/ClientsGrpcServiceGetManyClientsTests.cs @@ -0,0 +1,149 @@ +using Microsoft.EntityFrameworkCore; +using MicroZen.Api.Context; +using MicroZen.Api.Entities; +using MicroZen.Grpc.V1; +using static Xunit.Assert; +using ClientsService = MicroZen.Api.Services.ClientsGrpcService; + +namespace MicroZen.Core.Api.Test.Services; + +/// +/// Tests for +/// covering the search-term name filter fixed in Must Fix #50. The filter +/// originally interpolated the whole request message +/// ($"%{request}%") instead of the search term, so every search matched +/// the protobuf debug string and returned nothing. +/// +/// The fix also replaced the Npgsql-only EF.Functions.ILike with a +/// provider-agnostic, case-insensitive predicate +/// (c.Name.ToLower().Contains(term.ToLower())) so the behavior is +/// identical on PostgreSQL yet translatable/evaluable under the EF Core +/// InMemory provider this suite uses — no PostgreSQL required. +/// +public class ClientsGrpcServiceGetManyClientsTests : IDisposable +{ + private readonly DbContextOptions _options; + + public ClientsGrpcServiceGetManyClientsTests() + { + // Required by MicroZenContext.OnModelCreating (column encryption). + Environment.SetEnvironmentVariable("EncryptionKey", "1234567890"); + + // A unique database name isolates this fixture from other test classes. + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"GetManyClientsTests_{Guid.NewGuid():N}") + .Options; + } + + private async Task SeedAsync() + { + await using var ctx = new MicroZenContext(_options); + // Description is non-null because Client.ToMessage() maps it into a + // protobuf string field, which rejects null values. + ctx.Clients.AddRange( + new Client { Name = "Acme Portal", Type = ClientType.Api, OrganizationId = 1, Description = "Acme web portal" }, + new Client { Name = "Acme Admin", Type = ClientType.Api, OrganizationId = 1, Description = "Acme admin console" }, + new Client { Name = "Beta Service", Type = ClientType.Api, OrganizationId = 1, Description = "Beta backend service" }); + await ctx.SaveChangesAsync(); + } + + private async Task GetManyAsync(ManyClientsRequest request) + { + await using var ctx = new MicroZenContext(_options); + var service = new ClientsService(ctx); + return await service.GetManyClients(request, TestServerCallContext.Create()); + } + + [Fact] + public async Task GetManyClients_MatchingTerm_ReturnsMatchingClients() + { + // GIVEN three API clients, two named "Acme*". + await SeedAsync(); + + // WHEN searching for "Acme". + var response = await GetManyAsync(new ManyClientsRequest + { + Type = ClientType.Api, + SearchTerm = "Acme", + }); + + // THEN only the two Acme clients come back, not "Beta Service". + Equal(2, response.Clients.Count); + All(response.Clients, c => Contains("Acme", c.Name)); + DoesNotContain(response.Clients, c => c.Name == "Beta Service"); + } + + [Fact] + public async Task GetManyClients_SearchTerm_IsCaseInsensitive() + { + // GIVEN the seeded clients. + await SeedAsync(); + + // WHEN searching with a lower-case term. + var response = await GetManyAsync(new ManyClientsRequest + { + Type = ClientType.Api, + SearchTerm = "acme", + }); + + // THEN the same two Acme clients match (case-insensitive). + Equal(2, response.Clients.Count); + All(response.Clients, c => Contains("Acme", c.Name)); + } + + [Fact] + public async Task GetManyClients_NonMatchingTerm_ReturnsNone() + { + // GIVEN the seeded clients. + await SeedAsync(); + + // WHEN searching for a term no name contains. + var response = await GetManyAsync(new ManyClientsRequest + { + Type = ClientType.Api, + SearchTerm = "Zzz", + }); + + // THEN no clients come back. + Empty(response.Clients); + } + + [Fact] + public async Task GetManyClients_EmptySearchTerm_ReturnsAllOfType() + { + // GIVEN the seeded clients. + await SeedAsync(); + + // WHEN the search term is an empty string. + var response = await GetManyAsync(new ManyClientsRequest + { + Type = ClientType.Api, + SearchTerm = string.Empty, + }); + + // THEN all three clients of the type are returned (empty = no filter). + Equal(3, response.Clients.Count); + } + + [Fact] + public async Task GetManyClients_NullSearchTerm_ReturnsAllOfType() + { + // GIVEN the seeded clients. + await SeedAsync(); + + // WHEN no search term is set (SearchTerm wrapper unset / null). + var response = await GetManyAsync(new ManyClientsRequest + { + Type = ClientType.Api, + }); + + // THEN all three clients of the type are returned (no name filtering). + Equal(3, response.Clients.Count); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + Environment.SetEnvironmentVariable("EncryptionKey", null); + } +} diff --git a/core/csharp/MicroZen.Api.Test/Services/TestServerCallContext.cs b/core/csharp/MicroZen.Api.Test/Services/TestServerCallContext.cs new file mode 100644 index 0000000..83ffd00 --- /dev/null +++ b/core/csharp/MicroZen.Api.Test/Services/TestServerCallContext.cs @@ -0,0 +1,32 @@ +using System.Threading; +using Grpc.Core; + +namespace MicroZen.Core.Api.Test.Services; + +/// +/// Minimal for exercising gRPC service methods +/// directly in unit tests. The service methods under test do not read anything +/// off the context, so every member is a no-op / default. +/// +internal sealed class TestServerCallContext : ServerCallContext +{ + private TestServerCallContext() { } + + public static TestServerCallContext Create() => new(); + + protected override string MethodCore => string.Empty; + protected override string HostCore => string.Empty; + protected override string PeerCore => string.Empty; + protected override DateTime DeadlineCore => DateTime.MaxValue; + protected override Metadata RequestHeadersCore => new(); + protected override CancellationToken CancellationTokenCore => CancellationToken.None; + protected override Metadata ResponseTrailersCore => new(); + protected override Status StatusCore { get; set; } + protected override WriteOptions? WriteOptionsCore { get; set; } + protected override AuthContext AuthContextCore => new(string.Empty, new Dictionary>()); + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => + throw new NotSupportedException(); + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; +} diff --git a/core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs b/core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs index b0838ca..3737982 100644 --- a/core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs +++ b/core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs @@ -28,10 +28,15 @@ public override async Task GetManyClients(ManyClientsReques { var predicate = PredicateBuilder.New(true); predicate.And(c => c.Type == request.Type); - if (request.SearchTerm is not null) + if (!string.IsNullOrEmpty(request.SearchTerm)) { - predicate.And(c => - EF.Functions.ILike(c.Name, $"%{request}%")); + // Case-insensitive name contains. Provider-agnostic on purpose: + // Npgsql translates ToLower()/Contains() to SQL LOWER()/strpos, and + // the InMemory provider the test suite uses evaluates it in memory. + // (Replaces the Npgsql-only EF.Functions.ILike, which neither the + // InMemory nor Sqlite provider can translate — see PR description.) + var searchTerm = request.SearchTerm.ToLower(); + predicate.And(c => c.Name.ToLower().Contains(searchTerm)); } var response = new ManyClientsResponse();