---
paths:
  - "**/*.cs"
  - "**/ApiTests/**"
  - "**/Api.Tests/**"
---

# C# API Automation Testing

> Used by `/tas-apitest`. Extends [csharp/testing.md](./testing.md).

## Tech Stack

| Component | Choice |
|---|---|
| Framework | xUnit (default) — match project if already using MSTest/NUnit |
| Assertions | FluentAssertions |
| HTTP | System.Net.Http.HttpClient |
| Config | Microsoft.Extensions.Configuration + JSON/EnvVars |

```xml
<!-- ApiTests.csproj packages -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="FluentAssertions" Version="6.*" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.*" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.*" />
```

## Project Structure

```
tests/ApiTests/
  appsettings.json          # base (no real secrets)
  appsettings.Test.json     # Test env override
  appsettings.Staging.json  # Staging env override
  .gitignore                # appsettings.*.local.json
  Shared/
    ApiTestSettings.cs
    TestBase.cs
  v1/
    UsersApiTests.cs
  v2/                       # APPEND-ONLY: don't modify v1
    UsersApiTests.cs
```

## Config

```json
// appsettings.json
{
  "ApiTest": {
    "BaseUrl": "https://localhost:5001",
    "TimeoutSeconds": 30,
    "Auth": { "Type": "Bearer", "TokenEndpoint": "/api/auth/token", "Username": "", "Password": "" }
  }
}
```

```csharp
// ApiTestSettings.cs
public sealed class ApiTestSettings
{
    public required string BaseUrl { get; init; }
    public int TimeoutSeconds { get; init; } = 30;
    public required AuthSettings Auth { get; init; }
}
public sealed class AuthSettings
{
    public string Type { get; init; } = "Bearer";
    public string TokenEndpoint { get; init; } = "/api/auth/token";
    public string Username { get; init; } = "";
    public string Password { get; init; } = "";
}
```

```csharp
// TestBase.cs
public abstract class TestBase : IAsyncLifetime
{
    protected HttpClient Client { get; private set; } = null!;
    protected ApiTestSettings Settings { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Test";
        Settings = new ConfigurationBuilder()
            .AddJsonFile("appsettings.json")
            .AddJsonFile($"appsettings.{env}.json", optional: true)
            .AddEnvironmentVariables("APITEST_")
            .Build()
            .GetRequiredSection("ApiTest").Get<ApiTestSettings>()!;

        Client = new HttpClient { BaseAddress = new Uri(Settings.BaseUrl),
            Timeout = TimeSpan.FromSeconds(Settings.TimeoutSeconds) };

        if (!string.IsNullOrEmpty(Settings.Auth.Username))
            await AuthenticateAsync();
    }

    public Task DisposeAsync() { Client.Dispose(); return Task.CompletedTask; }

    protected async Task AuthenticateAsync(string? username = null, string? password = null)
    {
        var res = await Client.PostAsJsonAsync(Settings.Auth.TokenEndpoint,
            new { username = username ?? Settings.Auth.Username, password = password ?? Settings.Auth.Password });
        res.EnsureSuccessStatusCode();
        var token = (await res.Content.ReadFromJsonAsync<TokenResponse>())!.AccessToken;
        Client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
    }

    private sealed record TokenResponse(string AccessToken);
}
```

## Test Class Header (required)

```csharp
// ============================================================
// {Resource} API Tests — v{N}
// Spec: {spec-file}  |  Generated: {YYYY-MM-DD}  |  Feature: {ID}
// APPEND-ONLY: don't modify existing methods.
// ============================================================
namespace ApiTests.V{N};
public sealed class {Resource}ApiTests : TestBase
{
    // Inline DTOs — don't import from production code
    private sealed record {Resource}Dto(Guid Id, string Name);
    private sealed record ListResponse<T>(IReadOnlyList<T> Data, int Total);
}
```

## Test Naming

```
{HttpMethod}_{Resource}_Returns{Status}_When{Condition}
```

Example: `GetById_User_Returns200_WhenExists`, `Create_Order_Returns422_WhenEmailInvalid`

AC test: comment `// AC: {text}` right below XML doc.

## XML Doc (required on each test)

```csharp
/// <summary>Verify {METHOD} {path} → {status} when {condition}. Spec: {ref}</summary>
[Fact]
public async Task ...
```

## Coverage Matrix

| Condition | Status | When |
|---|---|---|
| Valid, authenticated | 2xx | Always |
| No token | 401 | Endpoint requires auth |
| Insufficient permission | 403 | RBAC / ownership |
| `{id}` doesn't exist | 404 | Has path param |
| Required field missing/invalid | 400/422 | Has request body |
| Business rule (from AC) | 4xx | Feature has corresponding AC |

## CI/CD Env Vars

```
ASPNETCORE_ENVIRONMENT=Test
APITEST__AUTH__USERNAME=...   # double __ for nested key
APITEST__AUTH__PASSWORD=...
```
