diff --git a/src/csharp/LampControlApi.Tests/InMemoryLampRepositoryTests.cs b/src/csharp/LampControlApi.Tests/InMemoryLampRepositoryTests.cs index 505378e7..1eb343b0 100644 --- a/src/csharp/LampControlApi.Tests/InMemoryLampRepositoryTests.cs +++ b/src/csharp/LampControlApi.Tests/InMemoryLampRepositoryTests.cs @@ -197,5 +197,74 @@ public async Task DeleteAsync_NonExistentLamp_ShouldReturnFalse() // Assert Assert.IsFalse(deleted); } + + /// + /// Test that ListAsync returns the correct page of lamps. + /// + /// A task. + [TestMethod] + public async Task ListAsync_ShouldReturnPagedResults() + { + // Arrange + var now = DateTimeOffset.UtcNow; + for (var i = 0; i < 5; i++) + { + var lamp = new LampEntity(Guid.NewGuid(), true, now.AddSeconds(i), now.AddSeconds(i)); + await _repository.CreateAsync(lamp); + } + + // Act + var page1 = await _repository.ListAsync(limit: 2, offset: 0); + var page2 = await _repository.ListAsync(limit: 2, offset: 2); + var page3 = await _repository.ListAsync(limit: 2, offset: 4); + + // Assert + Assert.AreEqual(2, page1.Count); + Assert.AreEqual(2, page2.Count); + Assert.AreEqual(1, page3.Count); + } + + /// + /// Test that ListAsync returns lamps ordered by CreatedAt then Id. + /// + /// A task. + [TestMethod] + public async Task ListAsync_ShouldReturnLampsOrderedByCreatedAt() + { + // Arrange + var now = DateTimeOffset.UtcNow; + var lamp1 = new LampEntity(Guid.NewGuid(), true, now.AddSeconds(2), now); + var lamp2 = new LampEntity(Guid.NewGuid(), false, now.AddSeconds(0), now); + var lamp3 = new LampEntity(Guid.NewGuid(), true, now.AddSeconds(1), now); + await _repository.CreateAsync(lamp1); + await _repository.CreateAsync(lamp2); + await _repository.CreateAsync(lamp3); + + // Act + var result = await _repository.ListAsync(limit: 3, offset: 0); + var list = result.ToList(); + + // Assert - should be oldest first + Assert.AreEqual(3, list.Count); + Assert.IsTrue(list[0].CreatedAt <= list[1].CreatedAt); + Assert.IsTrue(list[1].CreatedAt <= list[2].CreatedAt); + } + + /// + /// Test that ListAsync returns empty collection when offset exceeds total count. + /// + /// A task. + [TestMethod] + public async Task ListAsync_ShouldReturnEmpty_WhenOffsetExceedsCount() + { + // Arrange + await _repository.CreateAsync(LampEntity.Create(true)); + + // Act + var result = await _repository.ListAsync(limit: 10, offset: 100); + + // Assert + Assert.AreEqual(0, result.Count); + } } } diff --git a/src/csharp/LampControlApi.Tests/Infrastructure/PostgresLampRepositoryTests.cs b/src/csharp/LampControlApi.Tests/Infrastructure/PostgresLampRepositoryTests.cs index 7440a95d..8d657d48 100644 --- a/src/csharp/LampControlApi.Tests/Infrastructure/PostgresLampRepositoryTests.cs +++ b/src/csharp/LampControlApi.Tests/Infrastructure/PostgresLampRepositoryTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using DotNet.Testcontainers.Builders; using LampControlApi.Domain.Entities; @@ -275,6 +276,71 @@ public async Task GetAllAsync_ShouldNotReturnDeletedLamps() Assert.AreEqual(1, allLamps.Count); } + /// + /// Test that ListAsync returns the correct page of lamps from PostgreSQL. + /// + /// A representing the asynchronous unit test. + [TestMethod] + public async Task ListAsync_ShouldReturnPagedResults() + { + // Arrange - create 5 lamps + for (var i = 0; i < 5; i++) + { + await this.repository!.CreateAsync(LampEntity.Create(status: i % 2 == 0)); + } + + // Act + var page1 = await this.repository!.ListAsync(limit: 2, offset: 0); + var page2 = await this.repository.ListAsync(limit: 2, offset: 2); + var page3 = await this.repository.ListAsync(limit: 2, offset: 4); + + // Assert + Assert.AreEqual(2, page1.Count); + Assert.AreEqual(2, page2.Count); + Assert.AreEqual(1, page3.Count); + } + + /// + /// Test that ListAsync does not return soft-deleted lamps. + /// + /// A representing the asynchronous unit test. + [TestMethod] + public async Task ListAsync_ShouldNotReturnDeletedLamps() + { + // Arrange + var lamp1 = await this.repository!.CreateAsync(LampEntity.Create(status: true)); + await this.repository.CreateAsync(LampEntity.Create(status: false)); + await this.repository.DeleteAsync(lamp1.Id); + + // Act + var result = await this.repository.ListAsync(limit: 10, offset: 0); + + // Assert + Assert.AreEqual(1, result.Count); + } + + /// + /// Test that ListAsync returns lamps ordered by created_at then id. + /// + /// A representing the asynchronous unit test. + [TestMethod] + public async Task ListAsync_ShouldReturnLampsOrderedByCreatedAt() + { + // Arrange + await this.repository!.CreateAsync(LampEntity.Create(status: true)); + await this.repository.CreateAsync(LampEntity.Create(status: false)); + await this.repository.CreateAsync(LampEntity.Create(status: true)); + + // Act + var result = await this.repository.ListAsync(limit: 3, offset: 0); + var list = result.ToList(); + + // Assert - should be oldest first + Assert.AreEqual(3, list.Count); + Assert.IsTrue(list[0].CreatedAt <= list[1].CreatedAt); + Assert.IsTrue(list[1].CreatedAt <= list[2].CreatedAt); + } + /// /// Finds the repository root by searching upward for the .git directory. /// This is more robust than using relative paths with multiple ".." operators. diff --git a/src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs b/src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs index 90fd3f0e..d93bd44e 100644 --- a/src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs +++ b/src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs @@ -38,7 +38,7 @@ public void Constructor_WithNullRepository_ShouldThrowArgumentNullException() } /// - /// Test that ListLampsAsync returns empty collection when repository is empty. + /// Test that ListLampsAsync (parameterless) returns empty collection when repository is empty. /// /// A task. [TestMethod] @@ -46,7 +46,7 @@ public async Task ListLampsAsync_WhenRepositoryEmpty_ShouldReturnEmptyCollection { // Arrange var emptyLampEntities = new List(); - _mockRepository.Setup(r => r.GetAllAsync(It.IsAny())).ReturnsAsync(emptyLampEntities); + _mockRepository.Setup(r => r.ListAsync(int.MaxValue, 0, It.IsAny())).ReturnsAsync(emptyLampEntities); // Act var result = await _controller.ListLampsAsync(); @@ -54,11 +54,11 @@ public async Task ListLampsAsync_WhenRepositoryEmpty_ShouldReturnEmptyCollection // Assert Assert.IsNotNull(result); Assert.AreEqual(0, result.Count); - _mockRepository.Verify(r => r.GetAllAsync(It.IsAny()), Times.Once); + _mockRepository.Verify(r => r.ListAsync(int.MaxValue, 0, It.IsAny()), Times.Once); } /// - /// Test that ListLampsAsync returns all lamps from repository. + /// Test that ListLampsAsync (parameterless) returns all lamps from repository. /// /// A task. [TestMethod] @@ -71,7 +71,7 @@ public async Task ListLampsAsync_WithLampsInRepository_ShouldReturnAllLamps() new LampEntity(Guid.NewGuid(), false, DateTimeOffset.UtcNow.AddMinutes(-3), DateTimeOffset.UtcNow.AddMinutes(-2)), new LampEntity(Guid.NewGuid(), true, DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow) }; - _mockRepository.Setup(r => r.GetAllAsync(It.IsAny())).ReturnsAsync(expectedLampEntities); + _mockRepository.Setup(r => r.ListAsync(int.MaxValue, 0, It.IsAny())).ReturnsAsync(expectedLampEntities); // Act var result = await _controller.ListLampsAsync(); @@ -79,9 +79,129 @@ public async Task ListLampsAsync_WithLampsInRepository_ShouldReturnAllLamps() // Assert Assert.IsNotNull(result); Assert.AreEqual(expectedLampEntities.Count, result.Count); + _mockRepository.Verify(r => r.ListAsync(int.MaxValue, 0, It.IsAny()), Times.Once); + } + + /// + /// Test that ListLampsAsync with pageSize and cursor returns first page with HasMore=true. + /// + /// A task. + [TestMethod] + public async Task ListLampsAsync_WithPageSize_WhenMoreResultsExist_ShouldReturnHasMoreTrue() + { + // Arrange - repository returns pageSize+1 items, signalling there is a next page + var pageSize = 2; + var entities = new List + { + new LampEntity(Guid.NewGuid(), true, DateTimeOffset.UtcNow.AddMinutes(-3), DateTimeOffset.UtcNow), + new LampEntity(Guid.NewGuid(), false, DateTimeOffset.UtcNow.AddMinutes(-2), DateTimeOffset.UtcNow), + new LampEntity(Guid.NewGuid(), true, DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow), // extra item + }; + _mockRepository.Setup(r => r.ListAsync(pageSize + 1, 0, It.IsAny())).ReturnsAsync(entities); + + // Act + var actionResult = await _controller.ListLampsAsync(string.Empty, pageSize); + var response = actionResult.Value!; + + // Assert + Assert.IsNotNull(response); + Assert.AreEqual(pageSize, response.Data.Count); + Assert.IsTrue(response.HasMore); + Assert.AreEqual("2", response.NextCursor); + } + + /// + /// Test that ListLampsAsync with pageSize and cursor returns last page with HasMore=false. + /// + /// A task. + [TestMethod] + public async Task ListLampsAsync_WithPageSize_WhenOnLastPage_ShouldReturnHasMoreFalse() + { + // Arrange - repository returns fewer than pageSize+1 items, signalling this is the last page + var pageSize = 2; + var entities = new List + { + new LampEntity(Guid.NewGuid(), true, DateTimeOffset.UtcNow.AddMinutes(-2), DateTimeOffset.UtcNow), + new LampEntity(Guid.NewGuid(), false, DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow), + }; + _mockRepository.Setup(r => r.ListAsync(pageSize + 1, 0, It.IsAny())).ReturnsAsync(entities); - // Verify that the controller returns the correct number of lamps (they are mapped from entities to API models) - _mockRepository.Verify(r => r.GetAllAsync(It.IsAny()), Times.Once); + // Act + var actionResult = await _controller.ListLampsAsync(string.Empty, pageSize); + var response = actionResult.Value!; + + // Assert + Assert.IsNotNull(response); + Assert.AreEqual(2, response.Data.Count); + Assert.IsFalse(response.HasMore); + Assert.AreEqual(string.Empty, response.NextCursor); + } + + /// + /// Test that ListLampsAsync advances offset correctly using the cursor. + /// + /// A task. + [TestMethod] + public async Task ListLampsAsync_WithCursor_ShouldPassCorrectOffsetToRepository() + { + // Arrange - cursor="4" means offset 4, pageSize=2, so request limit=3 at offset=4 + var pageSize = 2; + var cursor = "4"; + var entities = new List + { + new LampEntity(Guid.NewGuid(), true, DateTimeOffset.UtcNow.AddMinutes(-2), DateTimeOffset.UtcNow), + }; + _mockRepository.Setup(r => r.ListAsync(pageSize + 1, 4, It.IsAny())).ReturnsAsync(entities); + + // Act + var actionResult = await _controller.ListLampsAsync(cursor, pageSize); + var response = actionResult.Value!; + + // Assert + Assert.IsNotNull(response); + Assert.AreEqual(1, response.Data.Count); + Assert.IsFalse(response.HasMore); + _mockRepository.Verify(r => r.ListAsync(pageSize + 1, 4, It.IsAny()), Times.Once); + } + + /// + /// Test that ListLampsAsync uses default page size when pageSize is zero or negative. + /// + /// A task. + [TestMethod] + public async Task ListLampsAsync_WithNonPositivePageSize_ShouldUseDefaultPageSize() + { + // Arrange - default page size is 25, so repository is called with limit=26 + var entities = new List(); + _mockRepository.Setup(r => r.ListAsync(26, 0, It.IsAny())).ReturnsAsync(entities); + + // Act + var actionResult = await _controller.ListLampsAsync(string.Empty, 0); + var response = actionResult.Value!; + + // Assert + Assert.IsNotNull(response); + _mockRepository.Verify(r => r.ListAsync(26, 0, It.IsAny()), Times.Once); + } + + /// + /// Test that ListLampsAsync clamps oversized pageSize to MaxPageSize. + /// + /// A task. + [TestMethod] + public async Task ListLampsAsync_WithExcessivePageSize_ShouldClampToMaxPageSize() + { + // Arrange - MaxPageSize=1000, so limit should be 1001 (1000+1) + var entities = new List(); + _mockRepository.Setup(r => r.ListAsync(1001, 0, It.IsAny())).ReturnsAsync(entities); + + // Act - pass a value far exceeding MaxPageSize + var actionResult = await _controller.ListLampsAsync(string.Empty, int.MaxValue); + var response = actionResult.Value!; + + // Assert + Assert.IsNotNull(response); + _mockRepository.Verify(r => r.ListAsync(1001, 0, It.IsAny()), Times.Once); } /// diff --git a/src/csharp/LampControlApi/Domain/Repositories/ILampRepository.cs b/src/csharp/LampControlApi/Domain/Repositories/ILampRepository.cs index 942a0e89..66fdb0ae 100644 --- a/src/csharp/LampControlApi/Domain/Repositories/ILampRepository.cs +++ b/src/csharp/LampControlApi/Domain/Repositories/ILampRepository.cs @@ -19,6 +19,24 @@ public interface ILampRepository /// Collection of all lamps. Task> GetAllAsync(CancellationToken cancellationToken = default); + /// + /// List lamps with database-level pagination. + /// + /// + /// Maximum number of lamps to return. Must be greater than or equal to 0. + /// A value of 0 returns an empty collection. Pass to + /// retrieve all remaining lamps from the given offset. + /// + /// + /// Number of lamps to skip before returning results. Must be greater than or equal to 0. + /// + /// Cancellation token. + /// Collection of lamps for the requested page, ordered by created_at then id. + /// + /// Thrown when or is negative. + /// + Task> ListAsync(int limit, int offset, CancellationToken cancellationToken = default); + /// /// Get a lamp by ID. /// diff --git a/src/csharp/LampControlApi/Services/InMemoryLampRepository.cs b/src/csharp/LampControlApi/Services/InMemoryLampRepository.cs index 0cf2c21b..5eeb8c31 100644 --- a/src/csharp/LampControlApi/Services/InMemoryLampRepository.cs +++ b/src/csharp/LampControlApi/Services/InMemoryLampRepository.cs @@ -34,6 +34,29 @@ public Task> GetAllAsync(CancellationToken cancellationT return Task.FromResult>(lamps); } + /// + public Task> ListAsync(int limit, int offset, CancellationToken cancellationToken = default) + { + if (limit < 0) + { + throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be greater than or equal to 0."); + } + + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), "Offset must be greater than or equal to 0."); + } + + cancellationToken.ThrowIfCancellationRequested(); + var page = _lamps.Values + .OrderBy(l => l.CreatedAt) + .ThenBy(l => l.Id) + .Skip(offset) + .Take(limit) + .ToList(); + return Task.FromResult>(page); + } + /// public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { diff --git a/src/csharp/LampControlApi/Services/LampControllerImplementation.cs b/src/csharp/LampControlApi/Services/LampControllerImplementation.cs index b670632c..ce77c4f1 100644 --- a/src/csharp/LampControlApi/Services/LampControllerImplementation.cs +++ b/src/csharp/LampControlApi/Services/LampControllerImplementation.cs @@ -15,6 +15,9 @@ namespace LampControlApi.Services /// public class LampControllerImplementation : IController { + private const int DefaultPageSize = 25; + private const int MaxPageSize = 1000; + private readonly ILampRepository _lampRepository; /// @@ -34,28 +37,28 @@ public LampControllerImplementation(ILampRepository lampRepository) /// A paginated response containing lamps. public async Task> ListLampsAsync(string cursor, int pageSize) { - // Normalize pageSize + // Normalize pageSize: default when missing/invalid, cap to prevent overflow on +1. if (pageSize <= 0) { - pageSize = 25; + pageSize = DefaultPageSize; } - // Interpret cursor as a starting index. If parsing fails, start at 0. - var start = 0; + pageSize = Math.Min(pageSize, MaxPageSize); + + // Interpret cursor as a starting offset. If parsing fails, start at 0. + var offset = 0; if (!string.IsNullOrWhiteSpace(cursor) && int.TryParse(cursor, out var parsed)) { - start = Math.Max(0, parsed); + offset = Math.Max(0, parsed); } - var entities = await _lampRepository.GetAllAsync(); - var lamps = entities.Select(LampMapper.ToApiModel) - .OrderByDescending(l => l.UpdatedAt) - .ThenByDescending(l => l.Id) - .ToList(); + // Fetch one extra row to determine whether a next page exists, + // avoiding a separate COUNT(*) query. pageSize <= MaxPageSize, so +1 is safe. + var entities = await _lampRepository.ListAsync(pageSize + 1, offset); - var page = lamps.Skip(start).Take(pageSize).ToList(); - var hasMore = start + pageSize < lamps.Count; - var nextCursor = hasMore ? (start + pageSize).ToString() : string.Empty; + var hasMore = entities.Count > pageSize; + var page = entities.Take(pageSize).Select(LampMapper.ToApiModel).ToList(); + var nextCursor = hasMore ? (offset + pageSize).ToString() : string.Empty; var response = new Response { @@ -73,17 +76,9 @@ public async Task> ListLampsAsync(string cursor, int page /// All lamps from the repository. public async Task> ListLampsAsync() { - // Call the paginated implementation with defaults and return the data list. - var response = await ListLampsAsync(string.Empty, int.MaxValue); - - // If the paginated overload returns an ActionResult, unwrap the value. - if (response is ActionResult ar && ar.Value != null) - { - return ar.Value.Data; - } - - // Fallback: return empty list if something unexpected happens. - return new List(); + // Fetch all lamps with the same deterministic ordering as the paginated endpoint. + var entities = await _lampRepository.ListAsync(int.MaxValue, 0); + return entities.Select(LampMapper.ToApiModel).ToList(); } /// diff --git a/src/csharp/LampControlApi/Services/PostgresLampRepository.cs b/src/csharp/LampControlApi/Services/PostgresLampRepository.cs index 3a31d3a2..227a3414 100644 --- a/src/csharp/LampControlApi/Services/PostgresLampRepository.cs +++ b/src/csharp/LampControlApi/Services/PostgresLampRepository.cs @@ -45,6 +45,35 @@ public async Task> GetAllAsync(CancellationToken cancell return dbEntities.Select(this.MapToDomain).ToList(); } + /// + public async Task> ListAsync(int limit, int offset, CancellationToken cancellationToken = default) + { + if (limit < 0) + { + throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be greater than or equal to 0."); + } + + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), "Offset must be greater than or equal to 0."); + } + + this.logger.LogDebug( + "Listing lamps from PostgreSQL database (limit: {Limit}, offset: {Offset})", + limit, + offset); + + var dbEntities = await this.context.Lamps + .OrderBy(l => l.CreatedAt) + .ThenBy(l => l.Id) + .Skip(offset) + .Take(limit) + .AsNoTracking() + .ToListAsync(cancellationToken); + + return dbEntities.Select(this.MapToDomain).ToList(); + } + /// public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) {