diff --git a/core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj b/core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj index bd9d287..bac5c3d 100644 --- a/core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj +++ b/core/csharp/MicroZen.Api.Test/MicroZen.Api.Test.csproj @@ -27,6 +27,13 @@ + + diff --git a/core/csharp/MicroZen.Api.Test/Services/ClientsGrpcServiceTests.cs b/core/csharp/MicroZen.Api.Test/Services/ClientsGrpcServiceTests.cs new file mode 100644 index 0000000..8750f28 --- /dev/null +++ b/core/csharp/MicroZen.Api.Test/Services/ClientsGrpcServiceTests.cs @@ -0,0 +1,204 @@ +using System.Threading; +using Grpc.Core; +using Microsoft.EntityFrameworkCore; +using MicroZen.Api.Context; +using MicroZen.Api.Entities; +using MicroZen.Grpc.V1; +using static Xunit.Assert; +// The service class and its generated gRPC base share the simple name "ClientsGrpcService" +// (MicroZen.Api.Services vs MicroZen.Grpc.V1); alias the concrete service under test. +using ClientsGrpcService = MicroZen.Api.Services.ClientsGrpcService; + +namespace MicroZen.Core.Api.Test.Services; + +/// +/// Tests for paging behaviour (issue #51). +/// +/// Follows the reference shape in Context/MicroZenContextTests.cs: a unique +/// in-memory database per fixture, the EncryptionKey set in the constructor and +/// cleared in , and GIVEN / WHEN / THEN comments. +/// +/// After the #51 fix: +/// - request.Page is a 1-based page number (default 1). +/// - request.Skip is the page size (default 10). +/// - The offset is (page - 1) * pageSize, so pages never overlap. +/// - NextUrl carries page={page+1} and skip={pageSize}, and is absent on the last page. +/// - Total is set on every response. +/// +public class ClientsGrpcServiceTests : IDisposable +{ + private const int TotalClients = 25; + private const int PageSize = 10; + + private readonly DbContextOptions _options; + + public ClientsGrpcServiceTests() + { + // 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: $"ClientsGrpcServiceTests_{Guid.NewGuid():N}") + .Options; + } + + /// + /// Seeds API clients with zero-padded, name-ordered + /// names ("Client 01" .. "Client 25") so OrderBy(c => c.Name) is deterministic. + /// + private async Task SeedClientsAsync() + { + await using var context = new MicroZenContext(_options); + for (var i = 1; i <= TotalClients; i++) + { + context.Clients.Add(new Client + { + Name = $"Client {i:D2}", + Type = ClientType.Api, + Description = $"Seeded client {i:D2}", + }); + } + await context.SaveChangesAsync(); + } + + private static ManyClientsRequest Request(int page, int skip) => + new() { Type = ClientType.Api, Page = page, Skip = skip }; + + [Fact] + public async Task GetManyClients_Page1_ReturnsFirstWindowWithNextUrl() + { + // GIVEN 25 seeded API clients. + await SeedClientsAsync(); + await using var context = new MicroZenContext(_options); + var service = new ClientsGrpcService(context); + + // WHEN the first page is requested with a page size of 10. + var response = await service.GetManyClients(Request(page: 1, skip: PageSize), new TestServerCallContext()); + + // THEN the first 10 clients by name are returned, Total is 25, and NextUrl points to page 2. + Equal(PageSize, response.Clients.Count); + Equal(TotalClients, response.Total); + Equal( + new[] { "Client 01", "Client 02", "Client 03", "Client 04", "Client 05", "Client 06", "Client 07", "Client 08", "Client 09", "Client 10" }, + response.Clients.Select(c => c.Name)); + NotNull(response.NextUrl); + Contains("page=2", response.NextUrl); + Contains("skip=10", response.NextUrl); + } + + [Fact] + public async Task GetManyClients_Page2_IsNonOverlappingWithPage1() + { + // GIVEN 25 seeded API clients. + await SeedClientsAsync(); + await using var context = new MicroZenContext(_options); + var service = new ClientsGrpcService(context); + + // WHEN page 1 and page 2 are both requested with a page size of 10. + var page1 = await service.GetManyClients(Request(page: 1, skip: PageSize), new TestServerCallContext()); + var page2 = await service.GetManyClients(Request(page: 2, skip: PageSize), new TestServerCallContext()); + + // THEN page 2 returns clients 11..20 and shares no client with page 1. + Equal(PageSize, page2.Clients.Count); + Equal( + new[] { "Client 11", "Client 12", "Client 13", "Client 14", "Client 15", "Client 16", "Client 17", "Client 18", "Client 19", "Client 20" }, + page2.Clients.Select(c => c.Name)); + + var page1Names = page1.Clients.Select(c => c.Name).ToHashSet(); + var page2Names = page2.Clients.Select(c => c.Name).ToHashSet(); + Empty(page1Names.Intersect(page2Names)); + + NotNull(page2.NextUrl); + Contains("page=3", page2.NextUrl); + Contains("skip=10", page2.NextUrl); + } + + [Fact] + public async Task GetManyClients_LastPage_EmitsNoNextUrl() + { + // GIVEN 25 seeded API clients. + await SeedClientsAsync(); + await using var context = new MicroZenContext(_options); + var service = new ClientsGrpcService(context); + + // WHEN the final page (page 3, size 10) is requested. + var response = await service.GetManyClients(Request(page: 3, skip: PageSize), new TestServerCallContext()); + + // THEN clients 21..25 are returned, Total is 25, and there is no NextUrl. + Equal(5, response.Clients.Count); + Equal(TotalClients, response.Total); + Equal( + new[] { "Client 21", "Client 22", "Client 23", "Client 24", "Client 25" }, + response.Clients.Select(c => c.Name)); + True(string.IsNullOrEmpty(response.NextUrl)); + } + + [Fact] + public async Task GetManyClients_WithoutPagingArguments_AppliesDefaults() + { + // GIVEN 25 seeded API clients. + await SeedClientsAsync(); + await using var context = new MicroZenContext(_options); + var service = new ClientsGrpcService(context); + + // WHEN no paging arguments are supplied (Page=0, Skip=0). + var response = await service.GetManyClients(Request(page: 0, skip: 0), new TestServerCallContext()); + + // THEN it behaves as page 1 with the default size 10 (10 rows from offset 0). + Equal(PageSize, response.Clients.Count); + Equal(TotalClients, response.Total); + Equal( + new[] { "Client 01", "Client 02", "Client 03", "Client 04", "Client 05", "Client 06", "Client 07", "Client 08", "Client 09", "Client 10" }, + response.Clients.Select(c => c.Name)); + } + + [Fact] + public async Task GetManyClients_SetsTotalOnEveryResponse() + { + // GIVEN 25 seeded API clients. + await SeedClientsAsync(); + await using var context = new MicroZenContext(_options); + var service = new ClientsGrpcService(context); + + // WHEN each page is requested in turn. + var page1 = await service.GetManyClients(Request(page: 1, skip: PageSize), new TestServerCallContext()); + var page2 = await service.GetManyClients(Request(page: 2, skip: PageSize), new TestServerCallContext()); + var lastPage = await service.GetManyClients(Request(page: 3, skip: PageSize), new TestServerCallContext()); + + // THEN Total is set on every response, whether or not a next page exists. + Equal(TotalClients, page1.Total); + Equal(TotalClients, page2.Total); + Equal(TotalClients, lastPage.Total); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + Environment.SetEnvironmentVariable("EncryptionKey", null); + } + + /// + /// Minimal stub. + /// never reads the call context, so every member is a no-op default. + /// + private sealed class TestServerCallContext : ServerCallContext + { + 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/Protos/client.proto b/core/csharp/MicroZen.Api/Protos/client.proto index bcf6eb7..36c334a 100644 --- a/core/csharp/MicroZen.Api/Protos/client.proto +++ b/core/csharp/MicroZen.Api/Protos/client.proto @@ -129,9 +129,9 @@ message ManyClientsRequest { ClientType type = 1; // The search term to filter for Clients by google.protobuf.StringValue searchTerm = 2; - // How many clients to return. Defaults to 10 if none specified. + // The 1-based page number to return. Defaults to 1 if none specified. int32 page = 3; - // How many clients to skip. Defaults to 0 if none specified. + // The page size (number of clients per page). Defaults to 10 if none specified. int32 skip = 4; } diff --git a/core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs b/core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs index c0cbdeb..6ed38e4 100644 --- a/core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs +++ b/core/csharp/MicroZen.Api/Services/ClientsGrpcService.cs @@ -16,6 +16,11 @@ namespace MicroZen.Api.Services; /// public class ClientsGrpcService(MicroZenContext db) : MicroZen.Grpc.V1.ClientsGrpcService.ClientsGrpcServiceBase { + /// + /// The default number of clients to return per page when the caller does not specify a page size. + /// + private const int DefaultPageSize = 10; + /// public override async Task GetManyClients(ManyClientsRequest request, ServerCallContext context) { @@ -28,8 +33,10 @@ public override async Task GetManyClients(ManyClientsReques } var response = new ManyClientsResponse(); - var page = request.Page > 0 ? request.Page : 10; - var skip = request.Skip > 0 ? request.Skip : 0; + var page = request.Page > 0 ? request.Page : 1; // 1-based page number + var pageSize = request.Skip > 0 ? request.Skip : DefaultPageSize; // rows per page + var skip = (page - 1) * pageSize; + var total = await db.Clients.Where(predicate).CountAsync(); var clients = await db.Clients .Include(c => c.OAuth2Credentials) @@ -37,14 +44,17 @@ public override async Task GetManyClients(ManyClientsReques .Where(predicate) .OrderBy(c => c.Name) .Skip(skip) - .Take(page) + .Take(pageSize) .Select(c => c.ToMessage()) .ToListAsync(); - var anyMoreClients = await db.Clients.Where(predicate).Take(page + 1).Skip(skip).AnyAsync(); - if (anyMoreClients) + + // There is a next page only if more rows exist beyond the current window. + var hasMore = skip + clients.Count < total; + response.Total = total; + if (hasMore) { - response.NextUrl = $"clients?page={request.Page + 1}&skip={request.Skip}&searchTerm={request.SearchTerm}&type={request.Type.ToString().ToUpper()}"; - response.Total = total; + response.NextUrl = + $"clients?page={page + 1}&skip={pageSize}&searchTerm={request.SearchTerm}&type={request.Type.ToString().ToUpper()}"; } response.Clients.AddRange(clients); return response;