Files
jdescopingtool/PLANS/2026-01-06-api-client-tests-implementation.md
T
Joseph Doherty 3d63f8f2fc docs: fix ApiResult property names in test implementation plan
- 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.
2026-01-06 11:54:31 -05:00

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 tests
  • LookupApiClientIntegrationTests - 4 tests
  • AuthApiClientIntegrationTests - 5 tests

Package References Required:

  • Microsoft.AspNetCore.Mvc.Abstractions for ValidationProblemDetails in Client.Tests (for accurate test payloads)

Files Modified:

  • AuthenticationTests.cs - Updated to use ApiRoutes constants
  • JdeScoping.Client.Tests.csproj - Add Microsoft.AspNetCore.Mvc.Abstractions package reference

Files Created:

  • TestableApiClient.cs - Test helper for ApiClientBase
  • ApiClientBaseTests.cs - Full coverage of status code mappings (GET, POST, bytes, multipart)
  • SearchApiClientTests.cs - Search client unit tests
  • LookupApiClientTests.cs - Lookup client unit tests
  • AuthApiClientTests.cs - Auth client unit tests
  • FileApiClientTests.cs - File client unit tests
  • ClientIntegrationTestBase.cs - Shared base for integration tests
  • ClientIntegrationCollection.cs - xUnit collection definition
  • SearchApiClientIntegrationTests.cs - Search integration tests
  • LookupApiClientIntegrationTests.cs - Lookup integration tests
  • AuthApiClientIntegrationTests.cs - Auth integration tests

Design Notes:

  • All 6 ApiResult<T> cases tested once in ApiClientBaseTests (not repeated per client)
  • Non-GET paths (POST, bytes, multipart) explicitly tested for status code mapping
  • Uses Microsoft.AspNetCore.Mvc.ValidationProblemDetails for accurate validation error payloads
  • Cookie auth returns 401/403 (not redirects) - configured in AddWebApi DependencyInjection
  • FakeAuthService accepts any credentials for testing