diff --git a/PLANS/2026-01-06-api-client-tests-implementation.md b/PLANS/2026-01-06-api-client-tests-implementation.md new file mode 100644 index 0000000..0c0b2cd --- /dev/null +++ b/PLANS/2026-01-06-api-client-tests-implementation.md @@ -0,0 +1,2035 @@ +# 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` 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` 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: +```csharp +using JdeScoping.Core.ApiContracts; +``` + +**Step 2: Replace hardcoded routes** + +Replace all occurrences: +```csharp +// 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** + +```bash +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: +```csharp +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** + +```bash +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** + +```csharp +using JdeScoping.Client.Services; +using JdeScoping.Core.ApiContracts.Results; + +namespace JdeScoping.Client.Tests.Services; + +/// +/// Test wrapper to expose protected ApiClientBase methods for unit testing. +/// +public class TestableApiClient : ApiClientBase +{ + public TestableApiClient(HttpClient httpClient) : base(httpClient) { } + + public new Task> GetAsync(string route, CancellationToken ct = default) + => base.GetAsync(route, ct); + + public new Task> PostAsync(string route, TBody body, CancellationToken ct = default) + => base.PostAsync(route, body, ct); + + public new Task> PostAsync(string route, CancellationToken ct = default) + => base.PostAsync(route, ct); + + public new Task> GetBytesAsync(string route, CancellationToken ct = default) + => base.GetBytesAsync(route, ct); + + public new Task> PostForBytesAsync(string route, TBody body, CancellationToken ct = default) + => base.PostForBytesAsync(route, body, ct); + + public new Task> PostMultipartAsync(string route, Stream fileStream, string fileName, CancellationToken ct = default) + => base.PostMultipartAsync(route, fileStream, fileName, ct); +} +``` + +**Step 2: Verify file compiles** + +Run: `dotnet build tests/JdeScoping.Client.Tests` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +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** + +```csharp +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("/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** + +```bash +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: + +```csharp + [Fact] + public async Task GetAsync_Returns404_MapsToNotFound() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.NotFound); + + // Act + var result = await _client.GetAsync("/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("/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("/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("/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("/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** + +```bash +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: + +```csharp + // 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("/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("/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("/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("/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("/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("/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("/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** + +```bash +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: + +```csharp + // 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("/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("/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("/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("/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("/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("/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** + +```bash +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** + +```csharp +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 { 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 { 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** + +```bash +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** + +```csharp +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 + { + 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 + { + 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** + +```bash +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** + +```csharp +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** + +```bash +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** + +```csharp +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 + { + 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 + { + 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 + { + 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** + +```bash +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: + +```xml + +``` + +**Step 2: Verify build** + +Run: `dotnet build tests/JdeScoping.Api.IntegrationTests` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +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** + +```csharp +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; + +/// +/// Base class for API client integration tests. +/// Provides shared HttpClient with cookie handling for auth state. +/// +[Collection("ClientIntegration")] +public abstract class ClientIntegrationTestBase : IClassFixture +{ + 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); + } + + /// + /// Performs login with encrypted credentials. + /// + 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"}"); + } + + /// + /// Creates a fresh HttpClient without cookies for testing unauthorized scenarios. + /// + 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`: + +```csharp +namespace JdeScoping.Api.IntegrationTests.ClientIntegration; + +/// +/// Collection definition for client integration tests. +/// Prevents parallel execution to avoid auth state conflicts. +/// +[CollectionDefinition("ClientIntegration")] +public class ClientIntegrationCollection : ICollectionFixture +{ +} +``` + +**Step 3: Verify build** + +Run: `dotnet build tests/JdeScoping.Api.IntegrationTests` +Expected: Build succeeds + +**Step 4: Commit** + +```bash +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** + +```csharp +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** + +```bash +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** + +```csharp +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** + +```bash +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** + +```csharp +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** + +```bash +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** + +```bash +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` 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