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:
@@ -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;
|
||||
}
|
||||
}
|
||||
131
tests/api/MicCheck.Api.Tests.Integration/Common/HttpFile.cs
Normal file
131
tests/api/MicCheck.Api.Tests.Integration/Common/HttpFile.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user