Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@
<ProjectReference Include="../MicroZen.OAuth2/MicroZen.OAuth2.csproj" Aliases="oauth2" />
</ItemGroup>

<!--
MicroZen.OAuth2 compiles the same client.proto as MicroZen.Api, so both
assemblies define the MicroZen.Grpc.V1.* types (e.g. ClientType,
ManyClientsRequest). The test project references MicroZen.Api directly and
MicroZen.OAuth2 transitively, which makes those gRPC types ambiguous
(CS0433). Alias the transitive MicroZen.OAuth2 assembly so its copies leave
the global namespace and MicroZen.Grpc.V1.* resolves unambiguously to
MicroZen.Api.
-->
<Target Name="AliasOAuth2GrpcTypes" AfterTargets="ResolveReferences">
<ItemGroup>
<ReferencePath Condition="'%(FileName)' == 'MicroZen.OAuth2'">
<Aliases>MicroZenOAuth2</Aliases>
</ReferencePath>
</ItemGroup>
</Target>

<ItemGroup>
<Folder Include="Security\Encryption\Attributes\" />
<Folder Include="Security\Encryption\Converters\" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="MicroZen.Api.Services.ClientsGrpcService.GetManyClients"/>
/// covering the search-term name filter fixed in Must Fix #50. The filter
/// originally interpolated the whole request message
/// (<c>$"%{request}%"</c>) instead of the search term, so every search matched
/// the protobuf debug string and returned nothing.
///
/// The fix also replaced the Npgsql-only <c>EF.Functions.ILike</c> with a
/// provider-agnostic, case-insensitive predicate
/// (<c>c.Name.ToLower().Contains(term.ToLower())</c>) so the behavior is
/// identical on PostgreSQL yet translatable/evaluable under the EF Core
/// InMemory provider this suite uses — no PostgreSQL required.
/// </summary>
public class ClientsGrpcServiceGetManyClientsTests : IDisposable
{
private readonly DbContextOptions<MicroZenContext> _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<MicroZenContext>()
.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<ManyClientsResponse> 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);
}
}
32 changes: 32 additions & 0 deletions core/csharp/MicroZen.Api.Test/Services/TestServerCallContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Threading;
using Grpc.Core;

namespace MicroZen.Core.Api.Test.Services;

/// <summary>
/// Minimal <see cref="ServerCallContext"/> 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.
/// </summary>
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<string, List<AuthProperty>>());

protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
throw new NotSupportedException();

protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
}
11 changes: 8 additions & 3 deletions core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@ public override async Task<ManyClientsResponse> GetManyClients(ManyClientsReques
{
var predicate = PredicateBuilder.New<Client>(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();
Expand Down
Loading