- Replace IsApiError with IsError - Replace ApiError. with Error. - Replace ValidationError.Errors with ValidationError.FieldErrors These match the actual ApiResult<T> implementation. The tests themselves were already correct - subagents adapted during implementation.
62 KiB
API Client Tests Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
Goal: Update existing integration tests to use ApiRoutes.* constants and add comprehensive unit tests for API clients using ApiResult<T> pattern.
Architecture: Unit tests use MockHttpMessageHandler to mock HTTP responses and verify ApiClientBase maps status codes correctly. Integration tests use TestWebApplicationFactory with shared HttpClient for cookie-based auth. All 6 ApiResult<T> cases tested once in ApiClientBaseTests, with lean coverage (success + representative error) per client.
Tech Stack: xUnit, RichardSzalay.MockHttp, Shouldly, NSubstitute, Microsoft.AspNetCore.Mvc.Testing
Task 1: Update AuthenticationTests to Use ApiRoutes Constants
Files:
- Modify:
tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs
Step 1: Add using for ApiRoutes
Add at top of file:
using JdeScoping.Core.ApiContracts;
Step 2: Replace hardcoded routes
Replace all occurrences:
// Before:
"/api/auth/public-key" -> ApiRoutes.Auth.PublicKey
"/api/auth/login" -> ApiRoutes.Auth.Login
"/api/auth/logout" -> ApiRoutes.Auth.Logout
"/api/auth/me" -> ApiRoutes.Auth.Me
"/api/search" -> ApiRoutes.Search.Base
"/api/lookup/items?q=test" -> $"{ApiRoutes.Lookup.Items}?q=test"
"/api/lookup/profit-centers?q=test" -> $"{ApiRoutes.Lookup.ProfitCenters}?q=test"
"/api/lookup/work-centers?q=test" -> $"{ApiRoutes.Lookup.WorkCenters}?q=test"
"/api/lookup/operators?q=test" -> $"{ApiRoutes.Lookup.Operators}?q=test"
Step 3: Run tests to verify
Run: dotnet test tests/JdeScoping.Api.IntegrationTests --filter "FullyQualifiedName~AuthenticationTests" -v quiet
Expected: All tests pass
Step 4: Commit
git add tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs
git commit -m "refactor: use ApiRoutes constants in AuthenticationTests"
Task 2: Update FileControllerIntegrationTests to Use ApiRoutes Constants
Files:
- Modify:
tests/JdeScoping.Api.IntegrationTests/FileControllerIntegrationTests.cs
Step 1: Add using for ApiRoutes
Add at top of file:
using JdeScoping.Core.ApiContracts;
Step 2: Review and update routes
The file uses /api/file/... routes which may not match ApiRoutes.FileIO.* constants. Check if the routes align:
- Current:
/api/file/work-orders/template/{key},/api/file/part-numbers/template/{key} - ApiRoutes:
api/fileio/workorders/download,api/fileio/items/download
Note: These are different endpoints (legacy download by cache key vs new download). Leave these tests as-is if they test different endpoints, or update to test the new FileIO routes if those old endpoints were removed.
Step 3: Run tests to verify
Run: dotnet test tests/JdeScoping.Api.IntegrationTests --filter "FullyQualifiedName~FileControllerIntegrationTests" -v quiet
Expected: All tests pass (or determine if tests need updating based on route analysis)
Step 4: Commit
git add tests/JdeScoping.Api.IntegrationTests/FileControllerIntegrationTests.cs
git commit -m "refactor: add ApiRoutes import to FileControllerIntegrationTests"
Task 3: Create TestableApiClient for Unit Testing ApiClientBase
Files:
- Create:
tests/JdeScoping.Client.Tests/Services/TestableApiClient.cs
Step 1: Write the test helper class
using JdeScoping.Client.Services;
using JdeScoping.Core.ApiContracts.Results;
namespace JdeScoping.Client.Tests.Services;
/// <summary>
/// Test wrapper to expose protected ApiClientBase methods for unit testing.
/// </summary>
public class TestableApiClient : ApiClientBase
{
public TestableApiClient(HttpClient httpClient) : base(httpClient) { }
public new Task<ApiResult<T>> GetAsync<T>(string route, CancellationToken ct = default)
=> base.GetAsync<T>(route, ct);
public new Task<ApiResult<T>> PostAsync<T, TBody>(string route, TBody body, CancellationToken ct = default)
=> base.PostAsync<T, TBody>(route, body, ct);
public new Task<ApiResult<T>> PostAsync<T>(string route, CancellationToken ct = default)
=> base.PostAsync<T>(route, ct);
public new Task<ApiResult<byte[]>> GetBytesAsync(string route, CancellationToken ct = default)
=> base.GetBytesAsync(route, ct);
public new Task<ApiResult<byte[]>> PostForBytesAsync<TBody>(string route, TBody body, CancellationToken ct = default)
=> base.PostForBytesAsync<TBody>(route, body, ct);
public new Task<ApiResult<T>> PostMultipartAsync<T>(string route, Stream fileStream, string fileName, CancellationToken ct = default)
=> base.PostMultipartAsync<T>(route, fileStream, fileName, ct);
}
Step 2: Verify file compiles
Run: dotnet build tests/JdeScoping.Client.Tests
Expected: Build succeeds
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/TestableApiClient.cs
git commit -m "test: add TestableApiClient helper for ApiClientBase unit tests"
Task 4: Create ApiClientBaseTests - Status Code Mappings (GET)
Files:
- Create:
tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs
Step 1: Write the failing test for 200 OK
using System.Net;
using System.Text.Json;
using JdeScoping.Core.ApiContracts.Results;
using RichardSzalay.MockHttp;
using Shouldly;
namespace JdeScoping.Client.Tests.Services;
public class ApiClientBaseTests
{
private readonly MockHttpMessageHandler _mockHttp;
private readonly TestableApiClient _client;
public ApiClientBaseTests()
{
_mockHttp = new MockHttpMessageHandler();
var httpClient = new HttpClient(_mockHttp) { BaseAddress = new Uri("http://localhost/") };
_client = new TestableApiClient(httpClient);
}
public record TestDto(int Id, string Name);
[Fact]
public async Task GetAsync_Returns200_MapsToSuccessValue()
{
// Arrange
var expected = new TestDto(42, "Test");
_mockHttp.When("/api/test")
.Respond("application/json", JsonSerializer.Serialize(expected));
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Id.ShouldBe(42);
result.Value.Name.ShouldBe("Test");
}
}
Step 2: Run test to verify it passes
Run: dotnet test tests/JdeScoping.Client.Tests --filter "GetAsync_Returns200_MapsToSuccessValue" -v normal
Expected: PASS
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs
git commit -m "test: add ApiClientBaseTests with 200 OK mapping test"
Task 5: ApiClientBaseTests - Add All 6 Status Code Tests
Files:
- Modify:
tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs
Step 1: Add remaining status code tests
Add after the first test:
[Fact]
public async Task GetAsync_Returns404_MapsToNotFound()
{
// Arrange
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.NotFound);
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsNotFound.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_Returns400_WithValidationErrors_MapsToValidationError()
{
// Arrange - use actual ValidationProblemDetails structure to match production
var validationProblem = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails
{
Errors =
{
["Name"] = new[] { "Name is required" },
["Id"] = new[] { "Id must be positive" }
}
};
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.BadRequest, "application/json", JsonSerializer.Serialize(validationProblem, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsValidationError.ShouldBeTrue();
result.ValidationError.FieldErrors.ShouldContainKey("Name");
result.ValidationError.FieldErrors["Name"].ShouldContain("Name is required");
}
[Fact]
public async Task GetAsync_Returns401_MapsToUnauthorized()
{
// Arrange
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.Unauthorized);
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsUnauthorized.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_Returns403_MapsToForbidden()
{
// Arrange
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.Forbidden);
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsForbidden.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_Returns500_MapsToApiError()
{
// Arrange
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.InternalServerError, "text/plain", "Internal Server Error");
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsError.ShouldBeTrue();
result.Error.StatusCode.ShouldBe(500);
}
Step 2: Run all status code tests
Run: dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~ApiClientBaseTests" -v quiet
Expected: All 6 tests pass
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs
git commit -m "test: add all 6 status code mapping tests to ApiClientBaseTests"
Task 6: ApiClientBaseTests - Add Edge Case Tests
Files:
- Modify:
tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs
Step 1: Add edge case tests
Add at end of class:
// Edge cases
[Fact]
public async Task GetAsync_Returns200_EmptyBody_MapsToApiError()
{
// Arrange
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.OK, "application/json", "");
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsError.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_Returns200_InvalidJson_MapsToApiError()
{
// Arrange
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.OK, "application/json", "not valid json {{{");
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsError.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_Returns204_ForUnitType_MapsToSuccess()
{
// Arrange
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.NoContent);
// Act
var result = await _client.GetAsync<Unit>("/api/test");
// Assert
result.IsSuccess.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_Returns204_ForNonUnitType_MapsToApiError()
{
// Arrange
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.NoContent);
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsError.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_NetworkException_MapsToApiError()
{
// Arrange
_mockHttp.When("/api/test")
.Throw(new HttpRequestException("Network failure"));
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsError.ShouldBeTrue();
result.Error.Message.ShouldContain("Network failure");
}
[Fact]
public async Task GetAsync_Timeout_MapsToApiError()
{
// Arrange
_mockHttp.When("/api/test")
.Throw(new TaskCanceledException("Request timeout"));
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsError.ShouldBeTrue();
}
[Fact]
public async Task GetAsync_Returns400_WithoutValidationFormat_MapsToApiError()
{
// Arrange - Bad request without standard validation problem format
_mockHttp.When("/api/test")
.Respond(HttpStatusCode.BadRequest, "text/plain", "Bad request");
// Act
var result = await _client.GetAsync<TestDto>("/api/test");
// Assert
result.IsError.ShouldBeTrue();
result.Error.StatusCode.ShouldBe(400);
}
Step 2: Run all tests
Run: dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~ApiClientBaseTests" -v quiet
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs
git commit -m "test: add edge case tests to ApiClientBaseTests"
Task 7: ApiClientBaseTests - Add POST and Bytes Tests
Files:
- Modify:
tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs
Step 1: Add POST method tests
Add after edge case tests:
// POST tests
[Fact]
public async Task PostAsync_Returns200_MapsToSuccessValue()
{
// Arrange
var expected = new TestDto(99, "Created");
_mockHttp.When(HttpMethod.Post, "/api/test")
.Respond("application/json", JsonSerializer.Serialize(expected));
// Act
var result = await _client.PostAsync<TestDto, TestDto>("/api/test", new TestDto(0, "Input"));
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Id.ShouldBe(99);
}
[Fact]
public async Task PostAsync_Returns401_MapsToUnauthorized()
{
// Arrange
_mockHttp.When(HttpMethod.Post, "/api/test")
.Respond(HttpStatusCode.Unauthorized);
// Act
var result = await _client.PostAsync<TestDto, TestDto>("/api/test", new TestDto(0, "Input"));
// Assert
result.IsUnauthorized.ShouldBeTrue();
}
[Fact]
public async Task PostAsync_WithNoBody_Returns200()
{
// Arrange
_mockHttp.When(HttpMethod.Post, "/api/test")
.Respond("application/json", JsonSerializer.Serialize(new Unit()));
// Act
var result = await _client.PostAsync<Unit>("/api/test");
// Assert
result.IsSuccess.ShouldBeTrue();
}
// Bytes tests
[Fact]
public async Task GetBytesAsync_Returns200_MapsToSuccessBytes()
{
// Arrange
var expectedBytes = new byte[] { 0x50, 0x4B, 0x03, 0x04 }; // ZIP header
_mockHttp.When("/api/download")
.Respond("application/octet-stream", new MemoryStream(expectedBytes));
// Act
var result = await _client.GetBytesAsync("/api/download");
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBe(expectedBytes);
}
[Fact]
public async Task GetBytesAsync_Returns404_MapsToNotFound()
{
// Arrange
_mockHttp.When("/api/download")
.Respond(HttpStatusCode.NotFound);
// Act
var result = await _client.GetBytesAsync("/api/download");
// Assert
result.IsNotFound.ShouldBeTrue();
}
[Fact]
public async Task PostForBytesAsync_Returns200_MapsToSuccessBytes()
{
// Arrange
var expectedBytes = new byte[] { 0x50, 0x4B, 0x03, 0x04 };
_mockHttp.When(HttpMethod.Post, "/api/download")
.Respond("application/octet-stream", new MemoryStream(expectedBytes));
// Act
var result = await _client.PostForBytesAsync("/api/download", new { filter = "test" });
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBe(expectedBytes);
}
// Non-GET status code tests (verify same mapping logic applies to other methods)
[Fact]
public async Task PostAsync_Returns400_WithValidationErrors_MapsToValidationError()
{
// Arrange - verify POST path handles validation errors same as GET
var validationProblem = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails
{
Errors = { ["Field"] = new[] { "Required" } }
};
_mockHttp.When(HttpMethod.Post, "/api/test")
.Respond(HttpStatusCode.BadRequest, "application/json",
JsonSerializer.Serialize(validationProblem, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
// Act
var result = await _client.PostAsync<TestDto, TestDto>("/api/test", new TestDto(0, "Input"));
// Assert
result.IsValidationError.ShouldBeTrue();
}
[Fact]
public async Task GetBytesAsync_Returns500_MapsToApiError()
{
// Arrange - verify bytes path handles server errors
_mockHttp.When("/api/download")
.Respond(HttpStatusCode.InternalServerError, "text/plain", "Server error");
// Act
var result = await _client.GetBytesAsync("/api/download");
// Assert
result.IsError.ShouldBeTrue();
result.Error.StatusCode.ShouldBe(500);
}
[Fact]
public async Task PostMultipartAsync_Returns401_MapsToUnauthorized()
{
// Arrange - verify multipart path handles auth errors
_mockHttp.When(HttpMethod.Post, "/api/upload")
.Respond(HttpStatusCode.Unauthorized);
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
// Act
var result = await _client.PostMultipartAsync<TestDto>("/api/upload", stream, "test.xlsx");
// Assert
result.IsUnauthorized.ShouldBeTrue();
}
[Fact]
public async Task PostMultipartAsync_Returns200_MapsToSuccessValue()
{
// Arrange
var expected = new TestDto(1, "Uploaded");
_mockHttp.When(HttpMethod.Post, "/api/upload")
.With(req => req.Content?.Headers.ContentType?.MediaType == "multipart/form-data")
.Respond("application/json", JsonSerializer.Serialize(expected));
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
// Act
var result = await _client.PostMultipartAsync<TestDto>("/api/upload", stream, "test.xlsx");
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Id.ShouldBe(1);
}
Step 2: Run all tests
Run: dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~ApiClientBaseTests" -v quiet
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs
git commit -m "test: add POST and bytes tests to ApiClientBaseTests"
Task 8: Create SearchApiClientTests
Files:
- Create:
tests/JdeScoping.Client.Tests/Services/SearchApiClientTests.cs
Step 1: Write SearchApiClientTests
using System.Net;
using System.Text.Json;
using JdeScoping.Client.Services;
using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.ViewModels;
using RichardSzalay.MockHttp;
using Shouldly;
namespace JdeScoping.Client.Tests.Services;
public class SearchApiClientTests
{
private readonly MockHttpMessageHandler _mockHttp;
private readonly SearchApiClient _client;
public SearchApiClientTests()
{
_mockHttp = new MockHttpMessageHandler();
var httpClient = new HttpClient(_mockHttp) { BaseAddress = new Uri("http://localhost/") };
_client = new SearchApiClient(httpClient);
}
// Route verification tests
[Fact]
public async Task GetUserSearchesAsync_CallsCorrectRoute()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.Base}")
.Respond("application/json", "[]");
// Act
await _client.GetUserSearchesAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task GetQueuedSearchesAsync_CallsCorrectRoute()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.Queue}")
.Respond("application/json", "[]");
// Act
await _client.GetQueuedSearchesAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task GetSearchAsync_CallsCorrectRoute()
{
// Arrange
var searchId = 42;
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetById(searchId)}")
.Respond("application/json", JsonSerializer.Serialize(CreateTestSearch(searchId)));
// Act
await _client.GetSearchAsync(searchId);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task CreateSearchAsync_CallsCorrectRoute_WithPostMethod()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.Search.Base}")
.Respond("application/json", "123");
// Act
await _client.CreateSearchAsync(CreateTestSearch(0));
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task GetResultsAsync_CallsCorrectRoute()
{
// Arrange
var searchId = 42;
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetResults(searchId)}")
.Respond("application/octet-stream", new MemoryStream(new byte[] { 1, 2, 3 }));
// Act
await _client.GetResultsAsync(searchId);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
// Success tests
[Fact]
public async Task GetUserSearchesAsync_Success_ReturnsSearchList()
{
// Arrange
var searches = new List<SearchViewModel> { CreateTestSearch(1), CreateTestSearch(2) };
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.Base}")
.Respond("application/json", JsonSerializer.Serialize(searches));
// Act
var result = await _client.GetUserSearchesAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Count.ShouldBe(2);
result.Value[0].Id.ShouldBe(1);
}
[Fact]
public async Task GetSearchAsync_Success_ReturnsSearch()
{
// Arrange
var search = CreateTestSearch(42);
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetById(42)}")
.Respond("application/json", JsonSerializer.Serialize(search));
// Act
var result = await _client.GetSearchAsync(42);
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Id.ShouldBe(42);
}
[Fact]
public async Task CreateSearchAsync_Success_ReturnsId()
{
// Arrange
_mockHttp.When(HttpMethod.Post, $"http://localhost/{ApiRoutes.Search.Base}")
.Respond("application/json", "123");
// Act
var result = await _client.CreateSearchAsync(CreateTestSearch(0));
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBe(123);
}
[Fact]
public async Task GetQueuedSearchesAsync_Success_ReturnsSearchList()
{
// Arrange
var searches = new List<SearchViewModel> { CreateTestSearch(1) };
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.Queue}")
.Respond("application/json", JsonSerializer.Serialize(searches));
// Act
var result = await _client.GetQueuedSearchesAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Count.ShouldBe(1);
}
[Fact]
public async Task GetResultsAsync_Success_ReturnsBytes()
{
// Arrange - GetResultsAsync returns byte[] for Excel file
var expectedBytes = new byte[] { 0x50, 0x4B, 0x03, 0x04 }; // ZIP/XLSX header
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetResults(42)}")
.Respond("application/octet-stream", new MemoryStream(expectedBytes));
// Act
var result = await _client.GetResultsAsync(42);
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBe(expectedBytes);
}
[Fact]
public async Task CopySearchAsync_CallsCorrectRoute()
{
// Arrange
var searchId = 42;
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetCopy(searchId)}")
.Respond("application/json", JsonSerializer.Serialize(CreateTestSearch(100)));
// Act
await _client.CopySearchAsync(searchId);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
// Representative error tests
[Fact]
public async Task GetSearchAsync_404_ReturnsNotFound()
{
// Arrange
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetById(999)}")
.Respond(HttpStatusCode.NotFound);
// Act
var result = await _client.GetSearchAsync(999);
// Assert
result.IsNotFound.ShouldBeTrue();
}
[Fact]
public async Task GetUserSearchesAsync_401_ReturnsUnauthorized()
{
// Arrange
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.Base}")
.Respond(HttpStatusCode.Unauthorized);
// Act
var result = await _client.GetUserSearchesAsync();
// Assert
result.IsUnauthorized.ShouldBeTrue();
}
private static SearchViewModel CreateTestSearch(int id) => new()
{
Id = id,
Name = $"Search {id}",
UserName = "testuser",
Status = "New"
};
}
Step 2: Run tests
Run: dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~SearchApiClientTests" -v quiet
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/SearchApiClientTests.cs
git commit -m "test: add SearchApiClientTests with route and success/error tests"
Task 9: Create LookupApiClientTests
Files:
- Create:
tests/JdeScoping.Client.Tests/Services/LookupApiClientTests.cs
Step 1: Write LookupApiClientTests
using System.Net;
using System.Text.Json;
using JdeScoping.Client.Services;
using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.ViewModels;
using RichardSzalay.MockHttp;
using Shouldly;
namespace JdeScoping.Client.Tests.Services;
public class LookupApiClientTests
{
private readonly MockHttpMessageHandler _mockHttp;
private readonly LookupApiClient _client;
public LookupApiClientTests()
{
_mockHttp = new MockHttpMessageHandler();
var httpClient = new HttpClient(_mockHttp) { BaseAddress = new Uri("http://localhost/") };
_client = new LookupApiClient(httpClient);
}
// Route verification tests
[Fact]
public async Task FindItemsAsync_CallsCorrectRoute_WithQuery()
{
// Arrange
var query = "TEST123";
var expectedUrl = $"http://localhost/{ApiRoutes.Lookup.FindItems(query)}";
var request = _mockHttp.Expect(HttpMethod.Get, expectedUrl)
.Respond("application/json", "[]");
// Act
await _client.FindItemsAsync(query);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task FindProfitCentersAsync_CallsCorrectRoute()
{
// Arrange
var query = "PC1";
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Lookup.FindProfitCenters(query)}")
.Respond("application/json", "[]");
// Act
await _client.FindProfitCentersAsync(query);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task FindWorkCentersAsync_CallsCorrectRoute()
{
// Arrange
var query = "WC1";
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Lookup.FindWorkCenters(query)}")
.Respond("application/json", "[]");
// Act
await _client.FindWorkCentersAsync(query);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task FindOperatorsAsync_CallsCorrectRoute()
{
// Arrange
var query = "John";
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Lookup.FindOperators(query)}")
.Respond("application/json", "[]");
// Act
await _client.FindOperatorsAsync(query);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
// Query string encoding tests
[Fact]
public async Task FindItemsAsync_EncodesSpecialCharacters_InQueryString()
{
// Arrange - query with special characters that need URL encoding
var query = "TEST&ITEM=1";
var expectedUrl = $"http://localhost/{ApiRoutes.Lookup.FindItems(query)}";
// The URL should have encoded & as %26 and = as %3D
expectedUrl.ShouldContain("%26");
expectedUrl.ShouldContain("%3D");
var request = _mockHttp.Expect(HttpMethod.Get, expectedUrl)
.Respond("application/json", "[]");
// Act
await _client.FindItemsAsync(query);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task FindItemsAsync_EncodesSpaces_InQueryString()
{
// Arrange - query with spaces
var query = "TEST ITEM";
var expectedUrl = $"http://localhost/{ApiRoutes.Lookup.FindItems(query)}";
// The URL should have encoded space as %20
expectedUrl.ShouldContain("%20");
var request = _mockHttp.Expect(HttpMethod.Get, expectedUrl)
.Respond("application/json", "[]");
// Act
await _client.FindItemsAsync(query);
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
// Success tests
[Fact]
public async Task FindItemsAsync_Success_ReturnsItemList()
{
// Arrange
var items = new List<ItemViewModel>
{
new() { ShortItemNumber = "ITEM1", Description = "Item One" },
new() { ShortItemNumber = "ITEM2", Description = "Item Two" }
};
_mockHttp.When(HttpMethod.Get, "*")
.Respond("application/json", JsonSerializer.Serialize(items));
// Act
var result = await _client.FindItemsAsync("ITEM");
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Count.ShouldBe(2);
result.Value[0].ShortItemNumber.ShouldBe("ITEM1");
}
[Fact]
public async Task FindOperatorsAsync_Success_ReturnsUserList()
{
// Arrange
var users = new List<JdeUserViewModel>
{
new() { UserId = "USER1", UserName = "John Doe" }
};
_mockHttp.When(HttpMethod.Get, "*")
.Respond("application/json", JsonSerializer.Serialize(users));
// Act
var result = await _client.FindOperatorsAsync("John");
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Count.ShouldBe(1);
}
}
Step 2: Run tests
Run: dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~LookupApiClientTests" -v quiet
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/LookupApiClientTests.cs
git commit -m "test: add LookupApiClientTests with route and encoding tests"
Task 10: Create AuthApiClientTests
Files:
- Create:
tests/JdeScoping.Client.Tests/Services/AuthApiClientTests.cs
Step 1: Write AuthApiClientTests
using System.Net;
using System.Text.Json;
using JdeScoping.Client.Services;
using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.ApiContracts.Results;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Auth;
using RichardSzalay.MockHttp;
using Shouldly;
namespace JdeScoping.Client.Tests.Services;
public class AuthApiClientTests
{
private readonly MockHttpMessageHandler _mockHttp;
private readonly AuthApiClient _client;
public AuthApiClientTests()
{
_mockHttp = new MockHttpMessageHandler();
var httpClient = new HttpClient(_mockHttp) { BaseAddress = new Uri("http://localhost/") };
_client = new AuthApiClient(httpClient);
}
// Route verification tests
[Fact]
public async Task GetPublicKeyAsync_CallsCorrectRoute()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Auth.PublicKey}")
.Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")));
// Act
await _client.GetPublicKeyAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task LoginAsync_CallsCorrectRoute_WithPostMethod()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.Auth.Login}")
.Respond("application/json", JsonSerializer.Serialize(new LoginResultModel { Success = true }));
// Act
await _client.LoginAsync(new EncryptedLoginRequest("encrypted"));
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task LogoutAsync_CallsCorrectRoute_WithPostMethod()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.Auth.Logout}")
.Respond(HttpStatusCode.NoContent);
// Act
await _client.LogoutAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task GetCurrentUserAsync_CallsCorrectRoute()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Auth.Me}")
.Respond("application/json", JsonSerializer.Serialize(new UserInfo("testuser", "Test User")));
// Act
await _client.GetCurrentUserAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
// Success tests
[Fact]
public async Task GetPublicKeyAsync_Success_ReturnsPublicKey()
{
// Arrange
var publicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjAN...\n-----END PUBLIC KEY-----";
_mockHttp.When(HttpMethod.Get, "*")
.Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKey)));
// Act
var result = await _client.GetPublicKeyAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.PublicKeyPem.ShouldBe(publicKey);
}
[Fact]
public async Task LoginAsync_Success_ReturnsLoginResult()
{
// Arrange
var loginResult = new LoginResultModel
{
Success = true,
User = new UserInfo("testuser", "Test User")
};
_mockHttp.When(HttpMethod.Post, "*")
.Respond("application/json", JsonSerializer.Serialize(loginResult));
// Act
var result = await _client.LoginAsync(new EncryptedLoginRequest("encrypted"));
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Success.ShouldBeTrue();
result.Value.User?.Username.ShouldBe("testuser");
}
[Fact]
public async Task LogoutAsync_Success_ReturnsUnit()
{
// Arrange
_mockHttp.When(HttpMethod.Post, "*")
.Respond(HttpStatusCode.NoContent);
// Act
var result = await _client.LogoutAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
}
[Fact]
public async Task GetCurrentUserAsync_Success_ReturnsUserInfo()
{
// Arrange
var userInfo = new UserInfo("testuser", "Test User");
_mockHttp.When(HttpMethod.Get, "*")
.Respond("application/json", JsonSerializer.Serialize(userInfo));
// Act
var result = await _client.GetCurrentUserAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Username.ShouldBe("testuser");
}
}
Step 2: Run tests
Run: dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~AuthApiClientTests" -v quiet
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/AuthApiClientTests.cs
git commit -m "test: add AuthApiClientTests with route and success tests"
Task 11: Create FileApiClientTests
Files:
- Create:
tests/JdeScoping.Client.Tests/Services/FileApiClientTests.cs
Step 1: Write FileApiClientTests
using System.Net;
using System.Text.Json;
using JdeScoping.Client.Services;
using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.ViewModels;
using RichardSzalay.MockHttp;
using Shouldly;
namespace JdeScoping.Client.Tests.Services;
public class FileApiClientTests
{
private readonly MockHttpMessageHandler _mockHttp;
private readonly FileApiClient _client;
public FileApiClientTests()
{
_mockHttp = new MockHttpMessageHandler();
var httpClient = new HttpClient(_mockHttp) { BaseAddress = new Uri("http://localhost/") };
_client = new FileApiClient(httpClient);
}
// Route verification tests - Downloads
[Fact]
public async Task DownloadWorkOrdersTemplateAsync_CallsCorrectRoute_WithPostMethod()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.FileIO.DownloadWorkOrders}")
.Respond("application/octet-stream", new MemoryStream(new byte[] { 1, 2, 3 }));
// Act
await _client.DownloadWorkOrdersTemplateAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task DownloadItemsTemplateAsync_CallsCorrectRoute()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.FileIO.DownloadItems}")
.Respond("application/octet-stream", new MemoryStream(new byte[] { 1, 2, 3 }));
// Act
await _client.DownloadItemsTemplateAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task DownloadComponentLotsTemplateAsync_CallsCorrectRoute()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.FileIO.DownloadComponentLots}")
.Respond("application/octet-stream", new MemoryStream(new byte[] { 1, 2, 3 }));
// Act
await _client.DownloadComponentLotsTemplateAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task DownloadPartOperationsTemplateAsync_CallsCorrectRoute()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.FileIO.DownloadPartOperations}")
.Respond("application/octet-stream", new MemoryStream(new byte[] { 1, 2, 3 }));
// Act
await _client.DownloadPartOperationsTemplateAsync();
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
// Route verification tests - Uploads
[Fact]
public async Task UploadWorkOrdersAsync_CallsCorrectRoute_WithPostMethod()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.FileIO.UploadWorkOrders}")
.Respond("application/json", "[]");
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
// Act
await _client.UploadWorkOrdersAsync(stream, "test.xlsx");
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
[Fact]
public async Task UploadItemsAsync_CallsCorrectRoute()
{
// Arrange
var request = _mockHttp.Expect(HttpMethod.Post, $"http://localhost/{ApiRoutes.FileIO.UploadItems}")
.Respond("application/json", "[]");
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
// Act
await _client.UploadItemsAsync(stream, "test.xlsx");
// Assert
_mockHttp.GetMatchCount(request).ShouldBe(1);
}
// Success tests - Downloads
[Fact]
public async Task DownloadWorkOrdersTemplateAsync_Success_ReturnsBytes()
{
// Arrange
var expectedBytes = new byte[] { 0x50, 0x4B, 0x03, 0x04 }; // ZIP header
_mockHttp.When(HttpMethod.Post, "*")
.Respond("application/octet-stream", new MemoryStream(expectedBytes));
// Act
var result = await _client.DownloadWorkOrdersTemplateAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBe(expectedBytes);
}
[Fact]
public async Task DownloadWorkOrdersTemplateAsync_WithExistingData_SendsData()
{
// Arrange
var existingData = new List<WorkOrderViewModel>
{
new() { WorkOrderNumber = 12345 }
};
_mockHttp.When(HttpMethod.Post, "*")
.With(req => req.Content != null) // Verify body is sent
.Respond("application/octet-stream", new MemoryStream(new byte[] { 1, 2, 3 }));
// Act
var result = await _client.DownloadWorkOrdersTemplateAsync(existingData);
// Assert
result.IsSuccess.ShouldBeTrue();
}
// Success tests - Uploads
[Fact]
public async Task UploadWorkOrdersAsync_Success_ReturnsWorkOrderList()
{
// Arrange
var workOrders = new List<WorkOrderViewModel>
{
new() { WorkOrderNumber = 12345, Status = "Active" }
};
_mockHttp.When(HttpMethod.Post, "*")
.Respond("application/json", JsonSerializer.Serialize(workOrders));
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
// Act
var result = await _client.UploadWorkOrdersAsync(stream, "test.xlsx");
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Count.ShouldBe(1);
result.Value[0].WorkOrderNumber.ShouldBe(12345);
}
[Fact]
public async Task UploadItemsAsync_Success_ReturnsItemList()
{
// Arrange
var items = new List<ItemViewModel>
{
new() { ShortItemNumber = "ITEM1", Description = "Test Item" }
};
_mockHttp.When(HttpMethod.Post, "*")
.Respond("application/json", JsonSerializer.Serialize(items));
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
// Act
var result = await _client.UploadItemsAsync(stream, "test.xlsx");
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Count.ShouldBe(1);
}
// Error tests
[Fact]
public async Task DownloadWorkOrdersTemplateAsync_404_ReturnsNotFound()
{
// Arrange
_mockHttp.When(HttpMethod.Post, "*")
.Respond(HttpStatusCode.NotFound);
// Act
var result = await _client.DownloadWorkOrdersTemplateAsync();
// Assert
result.IsNotFound.ShouldBeTrue();
}
[Fact]
public async Task UploadWorkOrdersAsync_VerifiesMultipartContentType_AndFilename()
{
// Arrange - verify multipart structure and filename
_mockHttp.When(HttpMethod.Post, "*")
.With(req =>
{
var content = req.Content as MultipartFormDataContent;
if (content == null) return false;
// Check content type is multipart/form-data
var contentType = req.Content?.Headers.ContentType?.MediaType;
if (contentType != "multipart/form-data") return false;
// Check that filename is passed correctly
var contentDisposition = content.First().Headers.ContentDisposition;
return contentDisposition?.FileName?.Contains("test.xlsx") == true;
})
.Respond("application/json", "[]");
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
// Act
var result = await _client.UploadWorkOrdersAsync(stream, "test.xlsx");
// Assert
result.IsSuccess.ShouldBeTrue();
}
[Fact]
public async Task UploadWorkOrdersAsync_400_ReturnsValidationError()
{
// Arrange - use actual ValidationProblemDetails structure
var validationProblem = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails
{
Errors = { ["File"] = new[] { "Invalid file format" } }
};
_mockHttp.When(HttpMethod.Post, "*")
.Respond(HttpStatusCode.BadRequest, "application/json",
JsonSerializer.Serialize(validationProblem, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
// Act
var result = await _client.UploadWorkOrdersAsync(stream, "test.xlsx");
// Assert
result.IsValidationError.ShouldBeTrue();
}
}
Step 2: Run tests
Run: dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~FileApiClientTests" -v quiet
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Client.Tests/Services/FileApiClientTests.cs
git commit -m "test: add FileApiClientTests with route, success, and error tests"
Task 12: Add Project Reference to Client.Tests
Files:
- Modify:
tests/JdeScoping.Api.IntegrationTests/JdeScoping.Api.IntegrationTests.csproj
Step 1: Add Client project reference
The integration tests need access to the API client classes. Add project reference:
<ProjectReference Include="..\..\src\JdeScoping.Client\JdeScoping.Client.csproj" />
Step 2: Verify build
Run: dotnet build tests/JdeScoping.Api.IntegrationTests
Expected: Build succeeds
Step 3: Commit
git add tests/JdeScoping.Api.IntegrationTests/JdeScoping.Api.IntegrationTests.csproj
git commit -m "build: add Client project reference to integration tests"
Task 13: Create ClientIntegrationTestBase
Files:
- Create:
tests/JdeScoping.Api.IntegrationTests/ClientIntegration/ClientIntegrationTestBase.cs
Step 1: Create directory and base class
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JdeScoping.Client.Services;
using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.Models.Auth;
using Microsoft.AspNetCore.Mvc.Testing;
namespace JdeScoping.Api.IntegrationTests.ClientIntegration;
/// <summary>
/// Base class for API client integration tests.
/// Provides shared HttpClient with cookie handling for auth state.
/// </summary>
[Collection("ClientIntegration")]
public abstract class ClientIntegrationTestBase : IClassFixture<TestWebApplicationFactory>
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient SharedClient;
// API clients share the authenticated HttpClient
protected readonly SearchApiClient SearchClient;
protected readonly LookupApiClient LookupClient;
protected readonly AuthApiClient AuthClient;
protected readonly FileApiClient FileClient;
protected ClientIntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
SharedClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
AllowAutoRedirect = false
});
// All clients share the same HttpClient (cookie container)
SearchClient = new SearchApiClient(SharedClient);
LookupClient = new LookupApiClient(SharedClient);
FileClient = new FileApiClient(SharedClient);
AuthClient = new AuthApiClient(SharedClient);
}
/// <summary>
/// Performs login with encrypted credentials.
/// </summary>
protected async Task LoginAsync(string username = "testuser", string password = "testpass")
{
// Step 1: Get public key
var publicKeyResult = await AuthClient.GetPublicKeyAsync();
if (!publicKeyResult.IsSuccess)
throw new Exception("Failed to get public key");
// Step 2: Encrypt credentials
var loginModel = new LoginModel { Username = username, Password = password };
var json = JsonSerializer.Serialize(loginModel);
var plaintext = Encoding.UTF8.GetBytes(json);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyResult.Value.PublicKeyPem);
var ciphertext = rsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
// Step 3: Login
var encryptedRequest = new EncryptedLoginRequest(Convert.ToBase64String(ciphertext));
var loginResult = await AuthClient.LoginAsync(encryptedRequest);
if (!loginResult.IsSuccess || !loginResult.Value.Success)
throw new Exception($"Login failed: {loginResult.Value?.ErrorMessage ?? "Unknown error"}");
}
/// <summary>
/// Creates a fresh HttpClient without cookies for testing unauthorized scenarios.
/// </summary>
protected HttpClient CreateFreshClient() => Factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false,
AllowAutoRedirect = false
});
}
Step 2: Create collection definition
Create file tests/JdeScoping.Api.IntegrationTests/ClientIntegration/ClientIntegrationCollection.cs:
namespace JdeScoping.Api.IntegrationTests.ClientIntegration;
/// <summary>
/// Collection definition for client integration tests.
/// Prevents parallel execution to avoid auth state conflicts.
/// </summary>
[CollectionDefinition("ClientIntegration")]
public class ClientIntegrationCollection : ICollectionFixture<TestWebApplicationFactory>
{
}
Step 3: Verify build
Run: dotnet build tests/JdeScoping.Api.IntegrationTests
Expected: Build succeeds
Step 4: Commit
git add tests/JdeScoping.Api.IntegrationTests/ClientIntegration/
git commit -m "test: add ClientIntegrationTestBase with shared auth HttpClient"
Task 14: Create SearchApiClientIntegrationTests
Files:
- Create:
tests/JdeScoping.Api.IntegrationTests/ClientIntegration/SearchApiClientIntegrationTests.cs
Step 1: Write integration tests
using JdeScoping.Client.Services;
using Shouldly;
namespace JdeScoping.Api.IntegrationTests.ClientIntegration;
public class SearchApiClientIntegrationTests : ClientIntegrationTestBase
{
public SearchApiClientIntegrationTests(TestWebApplicationFactory factory) : base(factory) { }
[Fact]
public async Task GetUserSearchesAsync_WithAuth_ReturnsSearchList()
{
// Arrange
await LoginAsync();
// Act
var result = await SearchClient.GetUserSearchesAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldNotBeNull();
}
[Fact]
public async Task GetUserSearchesAsync_WithoutAuth_ReturnsUnauthorized()
{
// Arrange - use fresh client without cookies
var freshClient = new SearchApiClient(CreateFreshClient());
// Act
var result = await freshClient.GetUserSearchesAsync();
// Assert
result.IsUnauthorized.ShouldBeTrue();
}
[Fact]
public async Task GetSearchAsync_NotFound_ReturnsNotFound()
{
// Arrange
await LoginAsync();
var nonExistentId = 999999;
// Act
var result = await SearchClient.GetSearchAsync(nonExistentId);
// Assert
result.IsNotFound.ShouldBeTrue();
}
}
Step 2: Run tests
Run: dotnet test tests/JdeScoping.Api.IntegrationTests --filter "FullyQualifiedName~SearchApiClientIntegrationTests" -v normal
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Api.IntegrationTests/ClientIntegration/SearchApiClientIntegrationTests.cs
git commit -m "test: add SearchApiClientIntegrationTests"
Task 15: Create LookupApiClientIntegrationTests
Files:
- Create:
tests/JdeScoping.Api.IntegrationTests/ClientIntegration/LookupApiClientIntegrationTests.cs
Step 1: Write integration tests
using JdeScoping.Client.Services;
using Shouldly;
namespace JdeScoping.Api.IntegrationTests.ClientIntegration;
public class LookupApiClientIntegrationTests : ClientIntegrationTestBase
{
public LookupApiClientIntegrationTests(TestWebApplicationFactory factory) : base(factory) { }
[Fact]
public async Task FindItemsAsync_WithoutAuth_ReturnsSuccess()
{
// Lookup endpoints don't require auth
var freshClient = new LookupApiClient(CreateFreshClient());
// Act
var result = await freshClient.FindItemsAsync("test");
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldNotBeNull();
}
[Fact]
public async Task FindProfitCentersAsync_ReturnsSuccess()
{
// Act
var result = await LookupClient.FindProfitCentersAsync("test");
// Assert
result.IsSuccess.ShouldBeTrue();
}
[Fact]
public async Task FindWorkCentersAsync_ReturnsSuccess()
{
// Act
var result = await LookupClient.FindWorkCentersAsync("test");
// Assert
result.IsSuccess.ShouldBeTrue();
}
[Fact]
public async Task FindOperatorsAsync_ReturnsSuccess()
{
// Act
var result = await LookupClient.FindOperatorsAsync("test");
// Assert
result.IsSuccess.ShouldBeTrue();
}
}
Step 2: Run tests
Run: dotnet test tests/JdeScoping.Api.IntegrationTests --filter "FullyQualifiedName~LookupApiClientIntegrationTests" -v normal
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Api.IntegrationTests/ClientIntegration/LookupApiClientIntegrationTests.cs
git commit -m "test: add LookupApiClientIntegrationTests"
Task 16: Create AuthApiClientIntegrationTests
Files:
- Create:
tests/JdeScoping.Api.IntegrationTests/ClientIntegration/AuthApiClientIntegrationTests.cs
Step 1: Write integration tests
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JdeScoping.Client.Services;
using JdeScoping.Core.Models.Auth;
using Microsoft.AspNetCore.Mvc.Testing;
using Shouldly;
namespace JdeScoping.Api.IntegrationTests.ClientIntegration;
public class AuthApiClientIntegrationTests : ClientIntegrationTestBase
{
public AuthApiClientIntegrationTests(TestWebApplicationFactory factory) : base(factory) { }
[Fact]
public async Task GetPublicKeyAsync_ReturnsValidPublicKey()
{
// Act
var result = await AuthClient.GetPublicKeyAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.PublicKeyPem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
}
[Fact]
public async Task LoginAsync_WithValidCredentials_ReturnsSuccess()
{
// Arrange - get public key and encrypt credentials
var publicKeyResult = await AuthClient.GetPublicKeyAsync();
publicKeyResult.IsSuccess.ShouldBeTrue();
var loginModel = new LoginModel { Username = "testuser", Password = "testpass" };
var json = JsonSerializer.Serialize(loginModel);
var plaintext = Encoding.UTF8.GetBytes(json);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyResult.Value.PublicKeyPem);
var ciphertext = rsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
var encryptedRequest = new EncryptedLoginRequest(Convert.ToBase64String(ciphertext));
// Act
var result = await AuthClient.LoginAsync(encryptedRequest);
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Success.ShouldBeTrue();
result.Value.User.ShouldNotBeNull();
result.Value.User.Username.ShouldBe("testuser");
}
[Fact]
public async Task GetCurrentUserAsync_AfterLogin_ReturnsUserInfo()
{
// Arrange
await LoginAsync();
// Act
var result = await AuthClient.GetCurrentUserAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.Username.ShouldBe("testuser");
}
[Fact]
public async Task GetCurrentUserAsync_WithoutAuth_ReturnsUnauthorized()
{
// Arrange - use fresh client without cookies
var freshClient = new AuthApiClient(CreateFreshClient());
// Act
var result = await freshClient.GetCurrentUserAsync();
// Assert
result.IsUnauthorized.ShouldBeTrue();
}
[Fact]
public async Task LogoutAsync_AfterLogin_Success()
{
// Arrange - create fresh client for this test to avoid affecting other tests
var client = Factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
AllowAutoRedirect = false
});
var authClient = new AuthApiClient(client);
// Login first
var publicKeyResult = await authClient.GetPublicKeyAsync();
var loginModel = new LoginModel { Username = "testuser", Password = "testpass" };
var json = JsonSerializer.Serialize(loginModel);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyResult.Value.PublicKeyPem);
var ciphertext = rsa.Encrypt(Encoding.UTF8.GetBytes(json), RSAEncryptionPadding.OaepSHA256);
await authClient.LoginAsync(new EncryptedLoginRequest(Convert.ToBase64String(ciphertext)));
// Act
var result = await authClient.LogoutAsync();
// Assert
result.IsSuccess.ShouldBeTrue();
// Verify logged out - me endpoint should return unauthorized
var meResult = await authClient.GetCurrentUserAsync();
meResult.IsUnauthorized.ShouldBeTrue();
}
}
Step 2: Run tests
Run: dotnet test tests/JdeScoping.Api.IntegrationTests --filter "FullyQualifiedName~AuthApiClientIntegrationTests" -v normal
Expected: All tests pass
Step 3: Commit
git add tests/JdeScoping.Api.IntegrationTests/ClientIntegration/AuthApiClientIntegrationTests.cs
git commit -m "test: add AuthApiClientIntegrationTests"
Task 17: Run All Tests and Verify
Step 1: Build entire solution
Run: dotnet build
Expected: Build succeeds with no errors
Step 2: Run all Client.Tests
Run: dotnet test tests/JdeScoping.Client.Tests -v quiet
Expected: All tests pass
Step 3: Run all Api.IntegrationTests
Run: dotnet test tests/JdeScoping.Api.IntegrationTests -v quiet
Expected: All tests pass
Step 4: Commit final verification
git add -A
git commit -m "test: verify all API client tests pass"
Summary
Unit Tests Created (~75 tests):
ApiClientBaseTests- 24 tests (6 GET status codes, edge cases, POST 200/401/400 validation, bytes 200/404/500, multipart 200/401)SearchApiClientTests- 15 tests (routes, success for all methods including GetQueuedSearches/GetResults/CopySearch, errors)LookupApiClientTests- 10 tests (routes, encoding, success)AuthApiClientTests- 8 tests (routes, success)FileApiClientTests- 18 tests (routes, success, errors, multipart content-type/filename validation)
Integration Tests Created (~12 tests):
SearchApiClientIntegrationTests- 3 testsLookupApiClientIntegrationTests- 4 testsAuthApiClientIntegrationTests- 5 tests
Package References Required:
Microsoft.AspNetCore.Mvc.AbstractionsforValidationProblemDetailsin Client.Tests (for accurate test payloads)
Files Modified:
AuthenticationTests.cs- Updated to use ApiRoutes constantsJdeScoping.Client.Tests.csproj- Add Microsoft.AspNetCore.Mvc.Abstractions package reference
Files Created:
TestableApiClient.cs- Test helper for ApiClientBaseApiClientBaseTests.cs- Full coverage of status code mappings (GET, POST, bytes, multipart)SearchApiClientTests.cs- Search client unit testsLookupApiClientTests.cs- Lookup client unit testsAuthApiClientTests.cs- Auth client unit testsFileApiClientTests.cs- File client unit testsClientIntegrationTestBase.cs- Shared base for integration testsClientIntegrationCollection.cs- xUnit collection definitionSearchApiClientIntegrationTests.cs- Search integration testsLookupApiClientIntegrationTests.cs- Lookup integration testsAuthApiClientIntegrationTests.cs- Auth integration tests
Design Notes:
- All 6
ApiResult<T>cases tested once inApiClientBaseTests(not repeated per client) - Non-GET paths (POST, bytes, multipart) explicitly tested for status code mapping
- Uses
Microsoft.AspNetCore.Mvc.ValidationProblemDetailsfor accurate validation error payloads - Cookie auth returns 401/403 (not redirects) - configured in
AddWebApiDependencyInjection FakeAuthServiceaccepts any credentials for testing