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)
{