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();