# 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