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
69 changes: 69 additions & 0 deletions src/csharp/LampControlApi.Tests/InMemoryLampRepositoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,5 +197,74 @@ public async Task DeleteAsync_NonExistentLamp_ShouldReturnFalse()
// Assert
Assert.IsFalse(deleted);
}

/// <summary>
/// Test that ListAsync returns the correct page of lamps.
/// </summary>
/// <returns>A task.</returns>
[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);
}

/// <summary>
/// Test that ListAsync returns lamps ordered by CreatedAt then Id.
/// </summary>
/// <returns>A task.</returns>
[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);
}

/// <summary>
/// Test that ListAsync returns empty collection when offset exceeds total count.
/// </summary>
/// <returns>A task.</returns>
[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);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using LampControlApi.Domain.Entities;
Expand Down Expand Up @@ -275,6 +276,71 @@ public async Task GetAllAsync_ShouldNotReturnDeletedLamps()
Assert.AreEqual(1, allLamps.Count);
}

/// <summary>
/// Test that ListAsync returns the correct page of lamps from PostgreSQL.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[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);
}

/// <summary>
/// Test that ListAsync does not return soft-deleted lamps.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[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);
}

/// <summary>
/// Test that ListAsync returns lamps ordered by created_at then id.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[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);
}

/// <summary>
/// Finds the repository root by searching upward for the .git directory.
/// This is more robust than using relative paths with multiple ".." operators.
Expand Down
134 changes: 127 additions & 7 deletions src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,27 @@
}

/// <summary>
/// Test that ListLampsAsync returns empty collection when repository is empty.
/// Test that ListLampsAsync (parameterless) returns empty collection when repository is empty.
/// </summary>
/// <returns>A task.</returns>
[TestMethod]
public async Task ListLampsAsync_WhenRepositoryEmpty_ShouldReturnEmptyCollection()
{
// Arrange
var emptyLampEntities = new List<LampEntity>();
_mockRepository.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>())).ReturnsAsync(emptyLampEntities);
_mockRepository.Setup(r => r.ListAsync(int.MaxValue, 0, It.IsAny<CancellationToken>())).ReturnsAsync(emptyLampEntities);

// Act
var result = await _controller.ListLampsAsync();

// Assert
Assert.IsNotNull(result);
Assert.AreEqual(0, result.Count);
_mockRepository.Verify(r => r.GetAllAsync(It.IsAny<CancellationToken>()), Times.Once);
_mockRepository.Verify(r => r.ListAsync(int.MaxValue, 0, It.IsAny<CancellationToken>()), Times.Once);
}

/// <summary>
/// Test that ListLampsAsync returns all lamps from repository.
/// Test that ListLampsAsync (parameterless) returns all lamps from repository.
/// </summary>
/// <returns>A task.</returns>
[TestMethod]
Expand All @@ -71,17 +71,137 @@
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<CancellationToken>())).ReturnsAsync(expectedLampEntities);
_mockRepository.Setup(r => r.ListAsync(int.MaxValue, 0, It.IsAny<CancellationToken>())).ReturnsAsync(expectedLampEntities);

// Act
var result = await _controller.ListLampsAsync();

// Assert
Assert.IsNotNull(result);
Assert.AreEqual(expectedLampEntities.Count, result.Count);
_mockRepository.Verify(r => r.ListAsync(int.MaxValue, 0, It.IsAny<CancellationToken>()), Times.Once);
}

/// <summary>
/// Test that ListLampsAsync with pageSize and cursor returns first page with HasMore=true.
/// </summary>
/// <returns>A task.</returns>
[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<LampEntity>
{
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<CancellationToken>())).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);
}

/// <summary>
/// Test that ListLampsAsync with pageSize and cursor returns last page with HasMore=false.
/// </summary>
/// <returns>A task.</returns>
[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<LampEntity>
{
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<CancellationToken>())).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<CancellationToken>()), 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);
}

/// <summary>
/// Test that ListLampsAsync advances offset correctly using the cursor.
/// </summary>
/// <returns>A task.</returns>
[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<LampEntity>
{
new LampEntity(Guid.NewGuid(), true, DateTimeOffset.UtcNow.AddMinutes(-2), DateTimeOffset.UtcNow),
};
_mockRepository.Setup(r => r.ListAsync(pageSize + 1, 4, It.IsAny<CancellationToken>())).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<CancellationToken>()), Times.Once);
}

/// <summary>
/// Test that ListLampsAsync uses default page size when pageSize is zero or negative.
/// </summary>
/// <returns>A task.</returns>
[TestMethod]
public async Task ListLampsAsync_WithNonPositivePageSize_ShouldUseDefaultPageSize()
{
// Arrange - default page size is 25, so repository is called with limit=26
var entities = new List<LampEntity>();
_mockRepository.Setup(r => r.ListAsync(26, 0, It.IsAny<CancellationToken>())).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<CancellationToken>()), Times.Once);
}

/// <summary>
/// Test that ListLampsAsync clamps oversized pageSize to MaxPageSize.
/// </summary>
/// <returns>A task.</returns>
[TestMethod]
public async Task ListLampsAsync_WithExcessivePageSize_ShouldClampToMaxPageSize()
{
// Arrange - MaxPageSize=1000, so limit should be 1001 (1000+1)
var entities = new List<LampEntity>();
_mockRepository.Setup(r => r.ListAsync(1001, 0, It.IsAny<CancellationToken>())).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<CancellationToken>()), Times.Once);
}

/// <summary>
Expand Down Expand Up @@ -461,7 +581,7 @@
var actionResult = await _controller.DeleteLampAsync(lampId.ToString());

// Assert - No exception should be thrown and check for NoContentResult
Assert.IsInstanceOfType(actionResult, typeof(Microsoft.AspNetCore.Mvc.NoContentResult));

Check warning on line 584 in src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs

View workflow job for this annotation

GitHub Actions / Code Coverage (8.x)

Prefer the generic overload 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType<T>(object?)' instead of 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType(object?, System.Type?)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 584 in src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs

View workflow job for this annotation

GitHub Actions / Schemathesis API Testing (8.x)

Prefer the generic overload 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType<T>(object?)' instead of 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType(object?, System.Type?)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 584 in src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs

View workflow job for this annotation

GitHub Actions / Schemathesis API Testing (8.x)

Prefer the generic overload 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType<T>(object?)' instead of 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType(object?, System.Type?)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 584 in src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs

View workflow job for this annotation

GitHub Actions / Mode Testing (8.x)

Prefer the generic overload 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType<T>(object?)' instead of 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType(object?, System.Type?)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 584 in src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs

View workflow job for this annotation

GitHub Actions / Mode Testing (8.x)

Prefer the generic overload 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType<T>(object?)' instead of 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType(object?, System.Type?)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 584 in src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs

View workflow job for this annotation

GitHub Actions / Verify Publish Ready (8.x)

Prefer the generic overload 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType<T>(object?)' instead of 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType(object?, System.Type?)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 584 in src/csharp/LampControlApi.Tests/LampControllerImplementationTests.cs

View workflow job for this annotation

GitHub Actions / Verify Publish Ready (8.x)

Prefer the generic overload 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType<T>(object?)' instead of 'Microsoft.VisualStudio.TestTools.UnitTesting.Assert.IsInstanceOfType(object?, System.Type?)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)
_mockRepository.Verify(r => r.DeleteAsync(lampId, It.IsAny<CancellationToken>()), Times.Once);
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/csharp/LampControlApi/Domain/Repositories/ILampRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ public interface ILampRepository
/// <returns>Collection of all lamps.</returns>
Task<ICollection<LampEntity>> GetAllAsync(CancellationToken cancellationToken = default);

/// <summary>
/// List lamps with database-level pagination.
/// </summary>
/// <param name="limit">
/// Maximum number of lamps to return. Must be greater than or equal to 0.
/// A value of 0 returns an empty collection. Pass <see cref="int.MaxValue"/> to
/// retrieve all remaining lamps from the given offset.
/// </param>
/// <param name="offset">
/// Number of lamps to skip before returning results. Must be greater than or equal to 0.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of lamps for the requested page, ordered by created_at then id.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="limit"/> or <paramref name="offset"/> is negative.
/// </exception>
Task<ICollection<LampEntity>> ListAsync(int limit, int offset, CancellationToken cancellationToken = default);

/// <summary>
/// Get a lamp by ID.
/// </summary>
Expand Down
23 changes: 23 additions & 0 deletions src/csharp/LampControlApi/Services/InMemoryLampRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ public Task<ICollection<LampEntity>> GetAllAsync(CancellationToken cancellationT
return Task.FromResult<ICollection<LampEntity>>(lamps);
}

/// <inheritdoc/>
public Task<ICollection<LampEntity>> 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)
Comment on lines +51 to +55
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit/offset are not validated before applying Skip/Take. In LINQ-to-Objects, negative values can behave differently than EF Core (e.g., Skip(-1) effectively acts like Skip(0)), which can hide bugs and diverge from production behavior. Add guard clauses to enforce non-negative inputs (and optionally a max limit) for consistency with PostgresLampRepository.

Copilot uses AI. Check for mistakes.
.ToList();
return Task.FromResult<ICollection<LampEntity>>(page);
}

/// <inheritdoc/>
public Task<LampEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
Expand Down
Loading
Loading