Add MicCheck.Api.Tests.Integration project

NUnit integration tests that target a running API by URL and seed/clean
data directly in Postgres. Settings (BaseUrl, DbConnectionString) come
from .runsettings or environment variables so tests can be pointed at
any environment. HTTP requests live in HttpFiles/*.http; assertion
failures include a fresh DB snapshot of the relevant rows. First test
covers the flag-enabled path.
This commit is contained in:
2026-06-03 19:12:47 -07:00
parent d916549402
commit d1a41d84d7
11 changed files with 547 additions and 0 deletions

View File

@@ -1,4 +1,11 @@
<Solution> <Solution>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".gitignore" />
<File Path="CLAUDE.md" />
<File Path="docker-compose.yml" />
<File Path="readme.md" />
</Folder>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/admin/Admin.esproj"> <Project Path="src/admin/Admin.esproj">
<Build /> <Build />
@@ -10,5 +17,6 @@
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" /> <Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" />
<Project Path="tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -0,0 +1,78 @@
using System.Net.Http.Headers;
using System.Text;
namespace MicCheck.Api.Tests.Integration.Common;
public sealed class FlagApiHttpClient : IDisposable
{
private static readonly string HttpFilesDirectory = Path.Combine(AppContext.BaseDirectory, "HttpFiles");
private readonly HttpClient httpClient;
private readonly Dictionary<string, IReadOnlyDictionary<string, HttpFileRequest>> requestsByFile = new(StringComparer.OrdinalIgnoreCase);
public FlagApiHttpClient(IntegrationTestSettings settings)
{
var handler = new HttpClientHandler();
if (settings.AcceptAnyServerCertificate)
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
httpClient = new HttpClient(handler);
}
public async Task<HttpResponseMessage> SendAsync(
string httpFile,
string requestName,
IReadOnlyDictionary<string, string> variables,
CancellationToken ct = default)
{
var requests = LoadRequests(httpFile);
if (!requests.TryGetValue(requestName, out var request))
throw new InvalidOperationException(
$"Request '{requestName}' was not found in '{httpFile}'. Known requests: {string.Join(", ", requests.Keys)}.");
var resolved = request.WithVariables(variables);
var httpRequest = new HttpRequestMessage(new HttpMethod(resolved.Method), resolved.Url);
string? contentType = null;
foreach (var header in resolved.Headers)
{
if (string.Equals(header.Name, "Content-Type", StringComparison.OrdinalIgnoreCase))
{
contentType = header.Value;
continue;
}
if (!httpRequest.Headers.TryAddWithoutValidation(header.Name, header.Value))
throw new InvalidOperationException(
$"Could not add header '{header.Name}' on request '{requestName}'.");
}
if (resolved.Body is not null)
{
var content = new StringContent(resolved.Body, Encoding.UTF8);
if (contentType is not null)
content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
httpRequest.Content = content;
}
return await httpClient.SendAsync(httpRequest, ct);
}
public void Dispose() => httpClient.Dispose();
private IReadOnlyDictionary<string, HttpFileRequest> LoadRequests(string httpFile)
{
if (requestsByFile.TryGetValue(httpFile, out var cached))
return cached;
var path = Path.Combine(HttpFilesDirectory, httpFile);
if (!File.Exists(path))
throw new FileNotFoundException(
$"HTTP file '{httpFile}' was not found at '{path}'. Check the file is in HttpFiles/ and set to CopyToOutputDirectory.",
path);
var parsed = HttpFileParser.Parse(File.ReadAllText(path));
requestsByFile[httpFile] = parsed;
return parsed;
}
}

View File

@@ -0,0 +1,131 @@
using System.Text;
namespace MicCheck.Api.Tests.Integration.Common;
public sealed record HttpFileHeader(string Name, string Value);
public sealed record HttpFileRequest(string Name, string Method, string Url, IReadOnlyList<HttpFileHeader> Headers, string? Body)
{
public HttpFileRequest WithVariables(IReadOnlyDictionary<string, string> variables)
{
string Substitute(string input)
{
var result = input;
foreach (var (key, value) in variables)
result = result.Replace("{{" + key + "}}", value, StringComparison.Ordinal);
return result;
}
return new HttpFileRequest(
Name,
Method,
Substitute(Url),
Headers.Select(h => new HttpFileHeader(h.Name, Substitute(h.Value))).ToList(),
Body is null ? null : Substitute(Body));
}
}
public static class HttpFileParser
{
public static IReadOnlyDictionary<string, HttpFileRequest> Parse(string content)
{
var requests = new Dictionary<string, HttpFileRequest>(StringComparer.OrdinalIgnoreCase);
var blocks = SplitBlocks(content);
foreach (var block in blocks)
{
var parsed = ParseBlock(block);
if (parsed is not null)
requests[parsed.Name] = parsed;
}
return requests;
}
private static List<string> SplitBlocks(string content)
{
var blocks = new List<string>();
var current = new StringBuilder();
foreach (var rawLine in content.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
if (line.StartsWith("###", StringComparison.Ordinal))
{
if (current.Length > 0)
{
blocks.Add(current.ToString());
current.Clear();
}
current.AppendLine(line);
}
else
{
current.AppendLine(line);
}
}
if (current.Length > 0)
blocks.Add(current.ToString());
return blocks;
}
private static HttpFileRequest? ParseBlock(string block)
{
var lines = block.Split('\n').Select(l => l.TrimEnd('\r')).ToList();
if (lines.Count == 0)
return null;
var nameLine = lines[0];
if (!nameLine.StartsWith("###", StringComparison.Ordinal))
return null;
var name = nameLine.TrimStart('#').Trim();
if (string.IsNullOrEmpty(name))
return null;
var i = 1;
while (i < lines.Count && string.IsNullOrWhiteSpace(lines[i]))
i++;
if (i >= lines.Count)
return null;
var requestLineParts = lines[i].Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (requestLineParts.Length < 2)
throw new InvalidOperationException(
$"Request '{name}' is missing a method and URL line.");
var method = requestLineParts[0];
var url = requestLineParts[1];
i++;
var headers = new List<HttpFileHeader>();
while (i < lines.Count && !string.IsNullOrWhiteSpace(lines[i]))
{
var colon = lines[i].IndexOf(':');
if (colon <= 0)
throw new InvalidOperationException(
$"Request '{name}' has a malformed header line: '{lines[i]}'.");
headers.Add(new HttpFileHeader(
lines[i][..colon].Trim(),
lines[i][(colon + 1)..].Trim()));
i++;
}
while (i < lines.Count && string.IsNullOrWhiteSpace(lines[i]))
i++;
string? body = null;
if (i < lines.Count)
{
var bodyText = string.Join("\n", lines.Skip(i)).Trim();
if (bodyText.Length > 0)
body = bodyText;
}
return new HttpFileRequest(name, method, url, headers, body);
}
}

View File

@@ -0,0 +1,33 @@
using NUnit.Framework;
namespace MicCheck.Api.Tests.Integration.Common;
public sealed record IntegrationTestSettings(string BaseUrl, string DbConnectionString, bool AcceptAnyServerCertificate)
{
public static IntegrationTestSettings Load()
{
var baseUrl = Resolve("BaseUrl", "MICCHECK_API_BASE_URL")
?? throw new InvalidOperationException(
"BaseUrl is not set. Provide it via .runsettings (TestRunParameters.BaseUrl) or the MICCHECK_API_BASE_URL environment variable.");
var connectionString = Resolve("DbConnectionString", "MICCHECK_DB_CONNECTION_STRING")
?? throw new InvalidOperationException(
"DbConnectionString is not set. Provide it via .runsettings (TestRunParameters.DbConnectionString) or the MICCHECK_DB_CONNECTION_STRING environment variable.");
var acceptAnyCert = bool.TryParse(
Resolve("AcceptAnyServerCertificate", "MICCHECK_ACCEPT_ANY_SERVER_CERTIFICATE"),
out var parsed) && parsed;
return new IntegrationTestSettings(baseUrl.TrimEnd('/'), connectionString, acceptAnyCert);
}
private static string? Resolve(string runSettingsParameter, string environmentVariable)
{
var fromEnv = System.Environment.GetEnvironmentVariable(environmentVariable);
if (!string.IsNullOrWhiteSpace(fromEnv))
return fromEnv;
var fromRunSettings = TestContext.Parameters.Get(runSettingsParameter);
return string.IsNullOrWhiteSpace(fromRunSettings) ? null : fromRunSettings;
}
}

View File

@@ -0,0 +1,68 @@
using System.Data;
using System.Text;
using Npgsql;
namespace MicCheck.Api.Tests.Integration.Common;
public sealed class TestDatabase(string connectionString)
{
public async Task<T> ExecuteScalarAsync<T>(string sql, params (string Name, object Value)[] parameters)
{
await using var connection = await OpenAsync();
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync();
if (result is null || result is DBNull)
throw new InvalidOperationException($"Scalar query returned NULL. SQL: {sql}");
return (T)Convert.ChangeType(result, typeof(T), System.Globalization.CultureInfo.InvariantCulture);
}
public async Task ExecuteAsync(string sql, params (string Name, object Value)[] parameters)
{
await using var connection = await OpenAsync();
await using var command = CreateCommand(connection, sql, parameters);
await command.ExecuteNonQueryAsync();
}
public async Task<string> SnapshotRowsAsync(string sql, params (string Name, object Value)[] parameters)
{
await using var connection = await OpenAsync();
await using var command = CreateCommand(connection, sql, parameters);
await using var reader = await command.ExecuteReaderAsync();
var output = new StringBuilder();
output.AppendLine($"SQL: {sql}");
if (!reader.HasRows)
{
output.AppendLine("(no rows)");
return output.ToString();
}
var columnNames = Enumerable.Range(0, reader.FieldCount).Select(reader.GetName).ToList();
output.AppendLine(string.Join(" | ", columnNames));
while (await reader.ReadAsync())
{
var values = Enumerable.Range(0, reader.FieldCount)
.Select(i => reader.IsDBNull(i) ? "NULL" : reader.GetValue(i).ToString() ?? "");
output.AppendLine(string.Join(" | ", values));
}
return output.ToString();
}
private async Task<NpgsqlConnection> OpenAsync()
{
var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
return connection;
}
private static NpgsqlCommand CreateCommand(NpgsqlConnection connection, string sql, (string Name, object Value)[] parameters)
{
var command = new NpgsqlCommand(sql, connection);
foreach (var (name, value) in parameters)
command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
}

View File

@@ -0,0 +1,81 @@
using System.Text.Json;
using MicCheck.Api.Tests.Integration.Common;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Integration.Flags;
[TestFixture]
public class FlagEnabledTests
{
private IntegrationTestSettings settings = null!;
private TestDatabase db = null!;
private FlagApiHttpClient client = null!;
private FlagSeed? seed;
[OneTimeSetUp]
public void OneTimeSetUp()
{
settings = IntegrationTestSettings.Load();
db = new TestDatabase(settings.DbConnectionString);
client = new FlagApiHttpClient(settings);
}
[OneTimeTearDown]
public void OneTimeTearDown() => client.Dispose();
[SetUp]
public async Task SetUp()
=> seed = await FlagSeed.InsertEnabledFlagAsync(db, testRunTag: "FlagEnabled", enabled: true, value: null);
[TearDown]
public async Task TearDown()
{
if (seed is not null)
{
await seed.CleanupAsync(db);
seed = null;
}
}
[Test]
public async Task WhenAFeatureIsEnabledInEnvironment_ThenTheFlagApiReportsItEnabled()
{
var current = seed!;
using var response = await client.SendAsync(
httpFile: "Flags.http",
requestName: "GetFlags",
variables: new Dictionary<string, string>
{
["baseUrl"] = settings.BaseUrl,
["environmentKey"] = current.EnvironmentApiKey
});
var body = await response.Content.ReadAsStringAsync();
Assert.That(
response.IsSuccessStatusCode,
Is.True,
$"GET /api/v1/flags returned {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}. DB state:\n{await current.DescribeAsync(db)}");
var flags = JsonSerializer.Deserialize<List<FlagResponseDto>>(body, JsonOptions)
?? throw new InvalidOperationException("Response body was not a JSON array.");
var match = flags.SingleOrDefault(f => f.Feature.Name == current.FeatureName);
Assert.That(
match,
Is.Not.Null,
$"Feature '{current.FeatureName}' was not present in the flags response. Returned features: [{string.Join(", ", flags.Select(f => f.Feature.Name))}]. DB state:\n{await current.DescribeAsync(db)}");
Assert.That(
match!.Enabled,
Is.True,
$"Feature '{current.FeatureName}' was reported as disabled, but the seeded FeatureState has Enabled=true. DB state:\n{await current.DescribeAsync(db)}");
}
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private sealed record FlagResponseDto(int Id, FeatureSummaryDto Feature, bool Enabled, string? FeatureStateValue);
private sealed record FeatureSummaryDto(int Id, string Name);
}

View File

@@ -0,0 +1,70 @@
using MicCheck.Api.Tests.Integration.Common;
namespace MicCheck.Api.Tests.Integration.Flags;
public sealed record FlagSeed(int OrganizationId, int ProjectId, int EnvironmentId, int FeatureId, int FeatureStateId, string EnvironmentApiKey, string FeatureName)
{
public static async Task<FlagSeed> InsertEnabledFlagAsync(TestDatabase db, string testRunTag, bool enabled, string? value)
{
var unique = $"{testRunTag}-{Guid.NewGuid():N}"[..40];
var environmentApiKey = $"envkey_{Guid.NewGuid():N}";
var featureName = $"feature_{unique}";
var now = DateTimeOffset.UtcNow;
var organizationId = await db.ExecuteScalarAsync<int>(
"INSERT INTO \"Organizations\" (\"Name\", \"CreatedAt\") VALUES (@name, @createdAt) RETURNING \"Id\"",
("name", $"org_{unique}"),
("createdAt", now));
var projectId = await db.ExecuteScalarAsync<int>(
"INSERT INTO \"Projects\" (\"Name\", \"OrganizationId\", \"CreatedAt\", \"HideDisabledFlags\") VALUES (@name, @orgId, @createdAt, false) RETURNING \"Id\"",
("name", $"project_{unique}"),
("orgId", organizationId),
("createdAt", now));
var environmentId = await db.ExecuteScalarAsync<int>(
"INSERT INTO \"Environments\" (\"Name\", \"ApiKey\", \"ProjectId\", \"CreatedAt\") VALUES (@name, @apiKey, @projectId, @createdAt) RETURNING \"Id\"",
("name", $"env_{unique}"),
("apiKey", environmentApiKey),
("projectId", projectId),
("createdAt", now));
var featureId = await db.ExecuteScalarAsync<int>(
"INSERT INTO \"Features\" (\"Name\", \"Type\", \"DefaultEnabled\", \"ProjectId\", \"CreatedAt\") VALUES (@name, 0, @defaultEnabled, @projectId, @createdAt) RETURNING \"Id\"",
("name", featureName),
("defaultEnabled", enabled),
("projectId", projectId),
("createdAt", now));
var featureStateId = await db.ExecuteScalarAsync<int>(
"""
INSERT INTO "FeatureStates" ("FeatureId", "EnvironmentId", "Enabled", "Value", "CreatedAt", "UpdatedAt", "Version")
VALUES (@featureId, @environmentId, @enabled, @value, @createdAt, @updatedAt, 1)
RETURNING "Id"
""",
("featureId", featureId),
("environmentId", environmentId),
("enabled", enabled),
("value", (object?)value ?? DBNull.Value),
("createdAt", now),
("updatedAt", now));
return new FlagSeed(organizationId, projectId, environmentId, featureId, featureStateId, environmentApiKey, featureName);
}
public async Task CleanupAsync(TestDatabase db)
=> await db.ExecuteAsync(
"DELETE FROM \"Organizations\" WHERE \"Id\" = @orgId",
("orgId", OrganizationId));
public async Task<string> DescribeAsync(TestDatabase db)
=> await db.SnapshotRowsAsync(
"""
SELECT f."Id" AS "FeatureId", f."Name", f."DefaultEnabled",
fs."Id" AS "FeatureStateId", fs."Enabled", fs."Value", fs."EnvironmentId"
FROM "Features" f
JOIN "FeatureStates" fs ON fs."FeatureId" = f."Id"
WHERE f."Id" = @featureId
""",
("featureId", FeatureId));
}

View File

@@ -0,0 +1,4 @@
### GetFlags
GET {{baseUrl}}/api/v1/flags
X-Environment-Key: {{environmentKey}}
Accept: application/json

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="NUnit" Version="4.5.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
</ItemGroup>
<ItemGroup>
<None Update="HttpFiles\**\*.http">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.runsettings">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<TestRunParameters>
<Parameter name="BaseUrl" value="http://localhost:5000" />
<Parameter name="DbConnectionString" value="Host=localhost;Port=5432;Database=miccheck;Username=miccheck;Password=password" />
<Parameter name="AcceptAnyServerCertificate" value="true" />
</TestRunParameters>
</RunSettings>

View File

@@ -0,0 +1,38 @@
# MicCheck.Api.Tests.Integration
Black-box integration tests. Targets a running MicCheck.Api instance by URL and seeds/cleans data directly in the Postgres database.
## Running against an environment
Two settings are required: `BaseUrl` and `DbConnectionString`.
### Via .runsettings (recommended for local + CI)
```
dotnet test --settings local.runsettings
```
Copy `local.runsettings` to e.g. `staging.runsettings` and override the values for other environments.
### Via command-line
```
dotnet test -- TestRunParameters.Parameter\(name=\"BaseUrl\",value=\"https://staging.example.com\"\) ^
TestRunParameters.Parameter\(name=\"DbConnectionString\",value=\"Host=...\"\)
```
### Via environment variables (overrides .runsettings)
```
MICCHECK_API_BASE_URL=...
MICCHECK_DB_CONNECTION_STRING=...
MICCHECK_ACCEPT_ANY_SERVER_CERTIFICATE=true|false
```
## Patterns
- Tests seed their own data **directly in the database**, never through the API.
- Per-test data is seeded in `[SetUp]`. Reusable cross-test data goes in `[OneTimeSetUp]`.
- Cleanup runs in `[TearDown]` / `[OneTimeTearDown]`, also directly in the database.
- HTTP requests are defined in `HttpFiles/*.http` and executed via `FlagApiHttpClient`.
- Assertion failure messages include a fresh snapshot of the relevant rows in the database.