3d63f8f2fc
- Replace IsApiError with IsError - Replace ApiError. with Error. - Replace ValidationError.Errors with ValidationError.FieldErrors These match the actual ApiResult<T> implementation. The tests themselves were already correct - subagents adapted during implementation.
2036 lines
62 KiB
Markdown
2036 lines
62 KiB
Markdown
# API Client Tests Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
|
|
|
|
**Goal:** Update existing integration tests to use `ApiRoutes.*` constants and add comprehensive unit tests for API clients using `ApiResult<T>` pattern.
|
|
|
|
**Architecture:** Unit tests use `MockHttpMessageHandler` to mock HTTP responses and verify ApiClientBase maps status codes correctly. Integration tests use `TestWebApplicationFactory` with shared `HttpClient` for cookie-based auth. All 6 `ApiResult<T>` cases tested once in `ApiClientBaseTests`, with lean coverage (success + representative error) per client.
|
|
|
|
**Tech Stack:** xUnit, RichardSzalay.MockHttp, Shouldly, NSubstitute, Microsoft.AspNetCore.Mvc.Testing
|
|
|
|
---
|
|
|
|
## Task 1: Update AuthenticationTests to Use ApiRoutes Constants
|
|
|
|
**Files:**
|
|
- Modify: `tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs`
|
|
|
|
**Step 1: Add using for ApiRoutes**
|
|
|
|
Add at top of file:
|
|
```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;
|
|
|
|
/// <summary>
|
|
/// Test wrapper to expose protected ApiClientBase methods for unit testing.
|
|
/// </summary>
|
|
public class TestableApiClient : ApiClientBase
|
|
{
|
|
public TestableApiClient(HttpClient httpClient) : base(httpClient) { }
|
|
|
|
public new Task<ApiResult<T>> GetAsync<T>(string route, CancellationToken ct = default)
|
|
=> base.GetAsync<T>(route, ct);
|
|
|
|
public new Task<ApiResult<T>> PostAsync<T, TBody>(string route, TBody body, CancellationToken ct = default)
|
|
=> base.PostAsync<T, TBody>(route, body, ct);
|
|
|
|
public new Task<ApiResult<T>> PostAsync<T>(string route, CancellationToken ct = default)
|
|
=> base.PostAsync<T>(route, ct);
|
|
|
|
public new Task<ApiResult<byte[]>> GetBytesAsync(string route, CancellationToken ct = default)
|
|
=> base.GetBytesAsync(route, ct);
|
|
|
|
public new Task<ApiResult<byte[]>> PostForBytesAsync<TBody>(string route, TBody body, CancellationToken ct = default)
|
|
=> base.PostForBytesAsync<TBody>(route, body, ct);
|
|
|
|
public new Task<ApiResult<T>> PostMultipartAsync<T>(string route, Stream fileStream, string fileName, CancellationToken ct = default)
|
|
=> base.PostMultipartAsync<T>(route, fileStream, fileName, ct);
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify file compiles**
|
|
|
|
Run: `dotnet build tests/JdeScoping.Client.Tests`
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Id.ShouldBe(42);
|
|
result.Value.Name.ShouldBe("Test");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it passes**
|
|
|
|
Run: `dotnet test tests/JdeScoping.Client.Tests --filter "GetAsync_Returns200_MapsToSuccessValue" -v normal`
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsNotFound.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Returns400_WithValidationErrors_MapsToValidationError()
|
|
{
|
|
// Arrange - use actual ValidationProblemDetails structure to match production
|
|
var validationProblem = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails
|
|
{
|
|
Errors =
|
|
{
|
|
["Name"] = new[] { "Name is required" },
|
|
["Id"] = new[] { "Id must be positive" }
|
|
}
|
|
};
|
|
_mockHttp.When("/api/test")
|
|
.Respond(HttpStatusCode.BadRequest, "application/json", JsonSerializer.Serialize(validationProblem, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsValidationError.ShouldBeTrue();
|
|
result.ValidationError.FieldErrors.ShouldContainKey("Name");
|
|
result.ValidationError.FieldErrors["Name"].ShouldContain("Name is required");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Returns401_MapsToUnauthorized()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/test")
|
|
.Respond(HttpStatusCode.Unauthorized);
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsUnauthorized.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Returns403_MapsToForbidden()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/test")
|
|
.Respond(HttpStatusCode.Forbidden);
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsForbidden.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Returns500_MapsToApiError()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/test")
|
|
.Respond(HttpStatusCode.InternalServerError, "text/plain", "Internal Server Error");
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsError.ShouldBeTrue();
|
|
result.Error.StatusCode.ShouldBe(500);
|
|
}
|
|
```
|
|
|
|
**Step 2: Run all status code tests**
|
|
|
|
Run: `dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~ApiClientBaseTests" -v quiet`
|
|
Expected: All 6 tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsError.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Returns200_InvalidJson_MapsToApiError()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/test")
|
|
.Respond(HttpStatusCode.OK, "application/json", "not valid json {{{");
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsError.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Returns204_ForUnitType_MapsToSuccess()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/test")
|
|
.Respond(HttpStatusCode.NoContent);
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<Unit>("/api/test");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Returns204_ForNonUnitType_MapsToApiError()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/test")
|
|
.Respond(HttpStatusCode.NoContent);
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsError.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_NetworkException_MapsToApiError()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/test")
|
|
.Throw(new HttpRequestException("Network failure"));
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsError.ShouldBeTrue();
|
|
result.Error.Message.ShouldContain("Network failure");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Timeout_MapsToApiError()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/test")
|
|
.Throw(new TaskCanceledException("Request timeout"));
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsError.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAsync_Returns400_WithoutValidationFormat_MapsToApiError()
|
|
{
|
|
// Arrange - Bad request without standard validation problem format
|
|
_mockHttp.When("/api/test")
|
|
.Respond(HttpStatusCode.BadRequest, "text/plain", "Bad request");
|
|
|
|
// Act
|
|
var result = await _client.GetAsync<TestDto>("/api/test");
|
|
|
|
// Assert
|
|
result.IsError.ShouldBeTrue();
|
|
result.Error.StatusCode.ShouldBe(400);
|
|
}
|
|
```
|
|
|
|
**Step 2: Run all tests**
|
|
|
|
Run: `dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~ApiClientBaseTests" -v quiet`
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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<TestDto, TestDto>("/api/test", new TestDto(0, "Input"));
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Id.ShouldBe(99);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostAsync_Returns401_MapsToUnauthorized()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When(HttpMethod.Post, "/api/test")
|
|
.Respond(HttpStatusCode.Unauthorized);
|
|
|
|
// Act
|
|
var result = await _client.PostAsync<TestDto, TestDto>("/api/test", new TestDto(0, "Input"));
|
|
|
|
// Assert
|
|
result.IsUnauthorized.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostAsync_WithNoBody_Returns200()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When(HttpMethod.Post, "/api/test")
|
|
.Respond("application/json", JsonSerializer.Serialize(new Unit()));
|
|
|
|
// Act
|
|
var result = await _client.PostAsync<Unit>("/api/test");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
}
|
|
|
|
// Bytes tests
|
|
|
|
[Fact]
|
|
public async Task GetBytesAsync_Returns200_MapsToSuccessBytes()
|
|
{
|
|
// Arrange
|
|
var expectedBytes = new byte[] { 0x50, 0x4B, 0x03, 0x04 }; // ZIP header
|
|
_mockHttp.When("/api/download")
|
|
.Respond("application/octet-stream", new MemoryStream(expectedBytes));
|
|
|
|
// Act
|
|
var result = await _client.GetBytesAsync("/api/download");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.ShouldBe(expectedBytes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetBytesAsync_Returns404_MapsToNotFound()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When("/api/download")
|
|
.Respond(HttpStatusCode.NotFound);
|
|
|
|
// Act
|
|
var result = await _client.GetBytesAsync("/api/download");
|
|
|
|
// Assert
|
|
result.IsNotFound.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostForBytesAsync_Returns200_MapsToSuccessBytes()
|
|
{
|
|
// Arrange
|
|
var expectedBytes = new byte[] { 0x50, 0x4B, 0x03, 0x04 };
|
|
_mockHttp.When(HttpMethod.Post, "/api/download")
|
|
.Respond("application/octet-stream", new MemoryStream(expectedBytes));
|
|
|
|
// Act
|
|
var result = await _client.PostForBytesAsync("/api/download", new { filter = "test" });
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.ShouldBe(expectedBytes);
|
|
}
|
|
|
|
// Non-GET status code tests (verify same mapping logic applies to other methods)
|
|
|
|
[Fact]
|
|
public async Task PostAsync_Returns400_WithValidationErrors_MapsToValidationError()
|
|
{
|
|
// Arrange - verify POST path handles validation errors same as GET
|
|
var validationProblem = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails
|
|
{
|
|
Errors = { ["Field"] = new[] { "Required" } }
|
|
};
|
|
_mockHttp.When(HttpMethod.Post, "/api/test")
|
|
.Respond(HttpStatusCode.BadRequest, "application/json",
|
|
JsonSerializer.Serialize(validationProblem, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
|
|
|
// Act
|
|
var result = await _client.PostAsync<TestDto, TestDto>("/api/test", new TestDto(0, "Input"));
|
|
|
|
// Assert
|
|
result.IsValidationError.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetBytesAsync_Returns500_MapsToApiError()
|
|
{
|
|
// Arrange - verify bytes path handles server errors
|
|
_mockHttp.When("/api/download")
|
|
.Respond(HttpStatusCode.InternalServerError, "text/plain", "Server error");
|
|
|
|
// Act
|
|
var result = await _client.GetBytesAsync("/api/download");
|
|
|
|
// Assert
|
|
result.IsError.ShouldBeTrue();
|
|
result.Error.StatusCode.ShouldBe(500);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostMultipartAsync_Returns401_MapsToUnauthorized()
|
|
{
|
|
// Arrange - verify multipart path handles auth errors
|
|
_mockHttp.When(HttpMethod.Post, "/api/upload")
|
|
.Respond(HttpStatusCode.Unauthorized);
|
|
|
|
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
|
|
|
|
// Act
|
|
var result = await _client.PostMultipartAsync<TestDto>("/api/upload", stream, "test.xlsx");
|
|
|
|
// Assert
|
|
result.IsUnauthorized.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostMultipartAsync_Returns200_MapsToSuccessValue()
|
|
{
|
|
// Arrange
|
|
var expected = new TestDto(1, "Uploaded");
|
|
_mockHttp.When(HttpMethod.Post, "/api/upload")
|
|
.With(req => req.Content?.Headers.ContentType?.MediaType == "multipart/form-data")
|
|
.Respond("application/json", JsonSerializer.Serialize(expected));
|
|
|
|
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
|
|
|
|
// Act
|
|
var result = await _client.PostMultipartAsync<TestDto>("/api/upload", stream, "test.xlsx");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Id.ShouldBe(1);
|
|
}
|
|
```
|
|
|
|
**Step 2: Run all tests**
|
|
|
|
Run: `dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~ApiClientBaseTests" -v quiet`
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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<SearchViewModel> { CreateTestSearch(1), CreateTestSearch(2) };
|
|
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.Base}")
|
|
.Respond("application/json", JsonSerializer.Serialize(searches));
|
|
|
|
// Act
|
|
var result = await _client.GetUserSearchesAsync();
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Count.ShouldBe(2);
|
|
result.Value[0].Id.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSearchAsync_Success_ReturnsSearch()
|
|
{
|
|
// Arrange
|
|
var search = CreateTestSearch(42);
|
|
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetById(42)}")
|
|
.Respond("application/json", JsonSerializer.Serialize(search));
|
|
|
|
// Act
|
|
var result = await _client.GetSearchAsync(42);
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Id.ShouldBe(42);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateSearchAsync_Success_ReturnsId()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When(HttpMethod.Post, $"http://localhost/{ApiRoutes.Search.Base}")
|
|
.Respond("application/json", "123");
|
|
|
|
// Act
|
|
var result = await _client.CreateSearchAsync(CreateTestSearch(0));
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.ShouldBe(123);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetQueuedSearchesAsync_Success_ReturnsSearchList()
|
|
{
|
|
// Arrange
|
|
var searches = new List<SearchViewModel> { CreateTestSearch(1) };
|
|
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.Queue}")
|
|
.Respond("application/json", JsonSerializer.Serialize(searches));
|
|
|
|
// Act
|
|
var result = await _client.GetQueuedSearchesAsync();
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetResultsAsync_Success_ReturnsBytes()
|
|
{
|
|
// Arrange - GetResultsAsync returns byte[] for Excel file
|
|
var expectedBytes = new byte[] { 0x50, 0x4B, 0x03, 0x04 }; // ZIP/XLSX header
|
|
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetResults(42)}")
|
|
.Respond("application/octet-stream", new MemoryStream(expectedBytes));
|
|
|
|
// Act
|
|
var result = await _client.GetResultsAsync(42);
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.ShouldBe(expectedBytes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CopySearchAsync_CallsCorrectRoute()
|
|
{
|
|
// Arrange
|
|
var searchId = 42;
|
|
var request = _mockHttp.Expect(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetCopy(searchId)}")
|
|
.Respond("application/json", JsonSerializer.Serialize(CreateTestSearch(100)));
|
|
|
|
// Act
|
|
await _client.CopySearchAsync(searchId);
|
|
|
|
// Assert
|
|
_mockHttp.GetMatchCount(request).ShouldBe(1);
|
|
}
|
|
|
|
// Representative error tests
|
|
|
|
[Fact]
|
|
public async Task GetSearchAsync_404_ReturnsNotFound()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.GetById(999)}")
|
|
.Respond(HttpStatusCode.NotFound);
|
|
|
|
// Act
|
|
var result = await _client.GetSearchAsync(999);
|
|
|
|
// Assert
|
|
result.IsNotFound.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetUserSearchesAsync_401_ReturnsUnauthorized()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When(HttpMethod.Get, $"http://localhost/{ApiRoutes.Search.Base}")
|
|
.Respond(HttpStatusCode.Unauthorized);
|
|
|
|
// Act
|
|
var result = await _client.GetUserSearchesAsync();
|
|
|
|
// Assert
|
|
result.IsUnauthorized.ShouldBeTrue();
|
|
}
|
|
|
|
private static SearchViewModel CreateTestSearch(int id) => new()
|
|
{
|
|
Id = id,
|
|
Name = $"Search {id}",
|
|
UserName = "testuser",
|
|
Status = "New"
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~SearchApiClientTests" -v quiet`
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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<ItemViewModel>
|
|
{
|
|
new() { ShortItemNumber = "ITEM1", Description = "Item One" },
|
|
new() { ShortItemNumber = "ITEM2", Description = "Item Two" }
|
|
};
|
|
_mockHttp.When(HttpMethod.Get, "*")
|
|
.Respond("application/json", JsonSerializer.Serialize(items));
|
|
|
|
// Act
|
|
var result = await _client.FindItemsAsync("ITEM");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Count.ShouldBe(2);
|
|
result.Value[0].ShortItemNumber.ShouldBe("ITEM1");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindOperatorsAsync_Success_ReturnsUserList()
|
|
{
|
|
// Arrange
|
|
var users = new List<JdeUserViewModel>
|
|
{
|
|
new() { UserId = "USER1", UserName = "John Doe" }
|
|
};
|
|
_mockHttp.When(HttpMethod.Get, "*")
|
|
.Respond("application/json", JsonSerializer.Serialize(users));
|
|
|
|
// Act
|
|
var result = await _client.FindOperatorsAsync("John");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Count.ShouldBe(1);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~LookupApiClientTests" -v quiet`
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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<WorkOrderViewModel>
|
|
{
|
|
new() { WorkOrderNumber = 12345 }
|
|
};
|
|
|
|
_mockHttp.When(HttpMethod.Post, "*")
|
|
.With(req => req.Content != null) // Verify body is sent
|
|
.Respond("application/octet-stream", new MemoryStream(new byte[] { 1, 2, 3 }));
|
|
|
|
// Act
|
|
var result = await _client.DownloadWorkOrdersTemplateAsync(existingData);
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
}
|
|
|
|
// Success tests - Uploads
|
|
|
|
[Fact]
|
|
public async Task UploadWorkOrdersAsync_Success_ReturnsWorkOrderList()
|
|
{
|
|
// Arrange
|
|
var workOrders = new List<WorkOrderViewModel>
|
|
{
|
|
new() { WorkOrderNumber = 12345, Status = "Active" }
|
|
};
|
|
_mockHttp.When(HttpMethod.Post, "*")
|
|
.Respond("application/json", JsonSerializer.Serialize(workOrders));
|
|
|
|
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
|
|
|
|
// Act
|
|
var result = await _client.UploadWorkOrdersAsync(stream, "test.xlsx");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Count.ShouldBe(1);
|
|
result.Value[0].WorkOrderNumber.ShouldBe(12345);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UploadItemsAsync_Success_ReturnsItemList()
|
|
{
|
|
// Arrange
|
|
var items = new List<ItemViewModel>
|
|
{
|
|
new() { ShortItemNumber = "ITEM1", Description = "Test Item" }
|
|
};
|
|
_mockHttp.When(HttpMethod.Post, "*")
|
|
.Respond("application/json", JsonSerializer.Serialize(items));
|
|
|
|
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
|
|
|
|
// Act
|
|
var result = await _client.UploadItemsAsync(stream, "test.xlsx");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.Count.ShouldBe(1);
|
|
}
|
|
|
|
// Error tests
|
|
|
|
[Fact]
|
|
public async Task DownloadWorkOrdersTemplateAsync_404_ReturnsNotFound()
|
|
{
|
|
// Arrange
|
|
_mockHttp.When(HttpMethod.Post, "*")
|
|
.Respond(HttpStatusCode.NotFound);
|
|
|
|
// Act
|
|
var result = await _client.DownloadWorkOrdersTemplateAsync();
|
|
|
|
// Assert
|
|
result.IsNotFound.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UploadWorkOrdersAsync_VerifiesMultipartContentType_AndFilename()
|
|
{
|
|
// Arrange - verify multipart structure and filename
|
|
_mockHttp.When(HttpMethod.Post, "*")
|
|
.With(req =>
|
|
{
|
|
var content = req.Content as MultipartFormDataContent;
|
|
if (content == null) return false;
|
|
|
|
// Check content type is multipart/form-data
|
|
var contentType = req.Content?.Headers.ContentType?.MediaType;
|
|
if (contentType != "multipart/form-data") return false;
|
|
|
|
// Check that filename is passed correctly
|
|
var contentDisposition = content.First().Headers.ContentDisposition;
|
|
return contentDisposition?.FileName?.Contains("test.xlsx") == true;
|
|
})
|
|
.Respond("application/json", "[]");
|
|
|
|
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
|
|
|
|
// Act
|
|
var result = await _client.UploadWorkOrdersAsync(stream, "test.xlsx");
|
|
|
|
// Assert
|
|
result.IsSuccess.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UploadWorkOrdersAsync_400_ReturnsValidationError()
|
|
{
|
|
// Arrange - use actual ValidationProblemDetails structure
|
|
var validationProblem = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails
|
|
{
|
|
Errors = { ["File"] = new[] { "Invalid file format" } }
|
|
};
|
|
_mockHttp.When(HttpMethod.Post, "*")
|
|
.Respond(HttpStatusCode.BadRequest, "application/json",
|
|
JsonSerializer.Serialize(validationProblem, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
|
|
|
using var stream = new MemoryStream(new byte[] { 1, 2, 3 });
|
|
|
|
// Act
|
|
var result = await _client.UploadWorkOrdersAsync(stream, "test.xlsx");
|
|
|
|
// Assert
|
|
result.IsValidationError.ShouldBeTrue();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `dotnet test tests/JdeScoping.Client.Tests --filter "FullyQualifiedName~FileApiClientTests" -v quiet`
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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
|
|
<ProjectReference Include="..\..\src\JdeScoping.Client\JdeScoping.Client.csproj" />
|
|
```
|
|
|
|
**Step 2: Verify build**
|
|
|
|
Run: `dotnet build tests/JdeScoping.Api.IntegrationTests`
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```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;
|
|
|
|
/// <summary>
|
|
/// Base class for API client integration tests.
|
|
/// Provides shared HttpClient with cookie handling for auth state.
|
|
/// </summary>
|
|
[Collection("ClientIntegration")]
|
|
public abstract class ClientIntegrationTestBase : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
protected readonly TestWebApplicationFactory Factory;
|
|
protected readonly HttpClient SharedClient;
|
|
|
|
// API clients share the authenticated HttpClient
|
|
protected readonly SearchApiClient SearchClient;
|
|
protected readonly LookupApiClient LookupClient;
|
|
protected readonly AuthApiClient AuthClient;
|
|
protected readonly FileApiClient FileClient;
|
|
|
|
protected ClientIntegrationTestBase(TestWebApplicationFactory factory)
|
|
{
|
|
Factory = factory;
|
|
SharedClient = factory.CreateClient(new WebApplicationFactoryClientOptions
|
|
{
|
|
HandleCookies = true,
|
|
AllowAutoRedirect = false
|
|
});
|
|
|
|
// All clients share the same HttpClient (cookie container)
|
|
SearchClient = new SearchApiClient(SharedClient);
|
|
LookupClient = new LookupApiClient(SharedClient);
|
|
FileClient = new FileApiClient(SharedClient);
|
|
AuthClient = new AuthApiClient(SharedClient);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs login with encrypted credentials.
|
|
/// </summary>
|
|
protected async Task LoginAsync(string username = "testuser", string password = "testpass")
|
|
{
|
|
// Step 1: Get public key
|
|
var publicKeyResult = await AuthClient.GetPublicKeyAsync();
|
|
if (!publicKeyResult.IsSuccess)
|
|
throw new Exception("Failed to get public key");
|
|
|
|
// Step 2: Encrypt credentials
|
|
var loginModel = new LoginModel { Username = username, Password = password };
|
|
var json = JsonSerializer.Serialize(loginModel);
|
|
var plaintext = Encoding.UTF8.GetBytes(json);
|
|
|
|
using var rsa = RSA.Create();
|
|
rsa.ImportFromPem(publicKeyResult.Value.PublicKeyPem);
|
|
var ciphertext = rsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
|
|
|
|
// Step 3: Login
|
|
var encryptedRequest = new EncryptedLoginRequest(Convert.ToBase64String(ciphertext));
|
|
var loginResult = await AuthClient.LoginAsync(encryptedRequest);
|
|
|
|
if (!loginResult.IsSuccess || !loginResult.Value.Success)
|
|
throw new Exception($"Login failed: {loginResult.Value?.ErrorMessage ?? "Unknown error"}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a fresh HttpClient without cookies for testing unauthorized scenarios.
|
|
/// </summary>
|
|
protected HttpClient CreateFreshClient() => Factory.CreateClient(new WebApplicationFactoryClientOptions
|
|
{
|
|
HandleCookies = false,
|
|
AllowAutoRedirect = false
|
|
});
|
|
}
|
|
```
|
|
|
|
**Step 2: Create collection definition**
|
|
|
|
Create file `tests/JdeScoping.Api.IntegrationTests/ClientIntegration/ClientIntegrationCollection.cs`:
|
|
|
|
```csharp
|
|
namespace JdeScoping.Api.IntegrationTests.ClientIntegration;
|
|
|
|
/// <summary>
|
|
/// Collection definition for client integration tests.
|
|
/// Prevents parallel execution to avoid auth state conflicts.
|
|
/// </summary>
|
|
[CollectionDefinition("ClientIntegration")]
|
|
public class ClientIntegrationCollection : ICollectionFixture<TestWebApplicationFactory>
|
|
{
|
|
}
|
|
```
|
|
|
|
**Step 3: Verify build**
|
|
|
|
Run: `dotnet build tests/JdeScoping.Api.IntegrationTests`
|
|
Expected: Build succeeds
|
|
|
|
**Step 4: Commit**
|
|
|
|
```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<T>` cases tested once in `ApiClientBaseTests` (not repeated per client)
|
|
- Non-GET paths (POST, bytes, multipart) explicitly tested for status code mapping
|
|
- Uses `Microsoft.AspNetCore.Mvc.ValidationProblemDetails` for accurate validation error payloads
|
|
- Cookie auth returns 401/403 (not redirects) - configured in `AddWebApi` DependencyInjection
|
|
- `FakeAuthService` accepts any credentials for testing
|