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.