From d1a41d84d7b207e0bd73c62a1b9dc412474900da Mon Sep 17 00:00:00 2001 From: James Wampler Date: Wed, 3 Jun 2026 19:12:47 -0700 Subject: [PATCH] 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. --- MicCheck.slnx | 8 ++ .../Common/FlagApiHttpClient.cs | 78 +++++++++++ .../Common/HttpFile.cs | 131 ++++++++++++++++++ .../Common/IntegrationTestSettings.cs | 33 +++++ .../Common/TestDatabase.cs | 68 +++++++++ .../Flags/FlagEnabledTests.cs | 81 +++++++++++ .../Flags/FlagSeed.cs | 70 ++++++++++ .../HttpFiles/Flags.http | 4 + .../MicCheck.Api.Tests.Integration.csproj | 28 ++++ .../local.runsettings | 8 ++ .../MicCheck.Api.Tests.Integration/readme.md | 38 +++++ 11 files changed, 547 insertions(+) create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Common/FlagApiHttpClient.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Common/HttpFile.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Common/IntegrationTestSettings.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Common/TestDatabase.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Flags/FlagEnabledTests.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/Flags/FlagSeed.cs create mode 100644 tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Flags.http create mode 100644 tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj create mode 100644 tests/api/MicCheck.Api.Tests.Integration/local.runsettings create mode 100644 tests/api/MicCheck.Api.Tests.Integration/readme.md diff --git a/MicCheck.slnx b/MicCheck.slnx index dad015e..a0e117e 100644 --- a/MicCheck.slnx +++ b/MicCheck.slnx @@ -1,4 +1,11 @@ + + + + + + + @@ -10,5 +17,6 @@ + diff --git a/tests/api/MicCheck.Api.Tests.Integration/Common/FlagApiHttpClient.cs b/tests/api/MicCheck.Api.Tests.Integration/Common/FlagApiHttpClient.cs new file mode 100644 index 0000000..0ca5547 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Common/FlagApiHttpClient.cs @@ -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> 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 SendAsync( + string httpFile, + string requestName, + IReadOnlyDictionary 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 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; + } +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Common/HttpFile.cs b/tests/api/MicCheck.Api.Tests.Integration/Common/HttpFile.cs new file mode 100644 index 0000000..778fe02 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Common/HttpFile.cs @@ -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 Headers, string? Body) +{ + public HttpFileRequest WithVariables(IReadOnlyDictionary 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 Parse(string content) + { + var requests = new Dictionary(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 SplitBlocks(string content) + { + var blocks = new List(); + 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(); + 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); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Common/IntegrationTestSettings.cs b/tests/api/MicCheck.Api.Tests.Integration/Common/IntegrationTestSettings.cs new file mode 100644 index 0000000..0a7891f --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Common/IntegrationTestSettings.cs @@ -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; + } +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Common/TestDatabase.cs b/tests/api/MicCheck.Api.Tests.Integration/Common/TestDatabase.cs new file mode 100644 index 0000000..d10c242 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Common/TestDatabase.cs @@ -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 ExecuteScalarAsync(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 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 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; + } +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagEnabledTests.cs b/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagEnabledTests.cs new file mode 100644 index 0000000..afcfbff --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagEnabledTests.cs @@ -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 + { + ["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>(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); +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagSeed.cs b/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagSeed.cs new file mode 100644 index 0000000..92013d9 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/Flags/FlagSeed.cs @@ -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 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( + "INSERT INTO \"Organizations\" (\"Name\", \"CreatedAt\") VALUES (@name, @createdAt) RETURNING \"Id\"", + ("name", $"org_{unique}"), + ("createdAt", now)); + + var projectId = await db.ExecuteScalarAsync( + "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( + "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( + "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( + """ + 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 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)); +} diff --git a/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Flags.http b/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Flags.http new file mode 100644 index 0000000..cdea794 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/HttpFiles/Flags.http @@ -0,0 +1,4 @@ +### GetFlags +GET {{baseUrl}}/api/v1/flags +X-Environment-Key: {{environmentKey}} +Accept: application/json diff --git a/tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj b/tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj new file mode 100644 index 0000000..50f7983 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + latest + false + true + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/tests/api/MicCheck.Api.Tests.Integration/local.runsettings b/tests/api/MicCheck.Api.Tests.Integration/local.runsettings new file mode 100644 index 0000000..3ad204d --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/local.runsettings @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/api/MicCheck.Api.Tests.Integration/readme.md b/tests/api/MicCheck.Api.Tests.Integration/readme.md new file mode 100644 index 0000000..3ef5a78 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Integration/readme.md @@ -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.