Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+157
@@ -0,0 +1,157 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// One isolated logical database per test class, on the shared
|
||||
/// <see cref="PostgresContainerFixture"/> server. Real EF Core
|
||||
/// migrations run at startup exactly as production does — this is not a mocked or
|
||||
/// SQLite-backed substitute (docs/22-testing-strategy.md).
|
||||
/// </summary>
|
||||
public sealed class KnowledgeHubIntegrationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
private readonly PostgresContainerFixture _postgres;
|
||||
private readonly IReadOnlyDictionary<string, string?> _additionalConfiguration;
|
||||
private readonly string _databaseName = $"test_{Guid.NewGuid():N}";
|
||||
|
||||
private readonly string _attachmentsDirectory =
|
||||
Path.Combine(Path.GetTempPath(), "ikh-tests-attachments", Guid.NewGuid().ToString("N"));
|
||||
private readonly string _importsDirectory =
|
||||
Path.Combine(Path.GetTempPath(), "ikh-tests-imports", Guid.NewGuid().ToString("N"));
|
||||
private readonly string _backupsDirectory =
|
||||
Path.Combine(Path.GetTempPath(), "ikh-tests-backups", Guid.NewGuid().ToString("N"));
|
||||
|
||||
public KnowledgeHubIntegrationFactory(
|
||||
PostgresContainerFixture postgres,
|
||||
IReadOnlyDictionary<string, string?>? additionalConfiguration = null)
|
||||
{
|
||||
_postgres = postgres;
|
||||
_additionalConfiguration = additionalConfiguration ?? new Dictionary<string, string?>();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = $"CREATE DATABASE \"{_databaseName}\"";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
async Task IAsyncLifetime.DisposeAsync()
|
||||
{
|
||||
// Dispose the hosted TestServer/HttpClient first (base WebApplicationFactory
|
||||
// behavior), then drop this test class's isolated database.
|
||||
await base.DisposeAsync();
|
||||
|
||||
|
||||
if (Directory.Exists(_attachmentsDirectory))
|
||||
{
|
||||
Directory.Delete(_attachmentsDirectory, recursive: true);
|
||||
}
|
||||
|
||||
if (Directory.Exists(_importsDirectory))
|
||||
{
|
||||
Directory.Delete(_importsDirectory, recursive: true);
|
||||
}
|
||||
|
||||
if (Directory.Exists(_backupsDirectory))
|
||||
{
|
||||
Directory.Delete(_backupsDirectory, recursive: true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString);
|
||||
await connection.OpenAsync();
|
||||
await using (var terminate = connection.CreateCommand())
|
||||
{
|
||||
terminate.CommandText =
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity " +
|
||||
"WHERE datname = @name AND pid <> pg_backend_pid()";
|
||||
terminate.Parameters.AddWithValue("name", _databaseName);
|
||||
await terminate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using var drop = connection.CreateCommand();
|
||||
drop.CommandText = $"DROP DATABASE IF EXISTS \"{_databaseName}\"";
|
||||
await drop.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch (NpgsqlException)
|
||||
{
|
||||
// Best-effort cleanup: the whole container is disposed at the end of the
|
||||
// run regardless, so a failure to drop one logical database here is not a
|
||||
// correctness problem for the test suite.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>The isolated content-addressed attachment storage root for this test
|
||||
/// class, so parallel test classes never share (or race on) blob files.</summary>
|
||||
public string AttachmentsDirectory => _attachmentsDirectory;
|
||||
|
||||
/// <summary>The isolated LeafWiki imports mount root for this test class. Tests
|
||||
/// write synthetic fixture files here directly (never a real snapshot).</summary>
|
||||
public string ImportsDirectory => _importsDirectory;
|
||||
|
||||
/// <summary>The isolated backups root for this test class, so parallel/sequential
|
||||
/// test classes never share (or have retention-prune) each other's backups.</summary>
|
||||
public string BackupsDirectory => _backupsDirectory;
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseContentRoot(
|
||||
Path.Combine(
|
||||
FindRepositoryRoot().FullName,
|
||||
"src",
|
||||
"backend",
|
||||
"ItWorx.KnowledgeHub.Api"));
|
||||
|
||||
builder.UseEnvironment("IntegrationTest");
|
||||
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["KnowledgeHub:Database:Host"] = _postgres.Host,
|
||||
["KnowledgeHub:Database:Port"] = _postgres.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["KnowledgeHub:Database:Name"] = _databaseName,
|
||||
["KnowledgeHub:Database:Username"] = PostgresContainerFixture.Username,
|
||||
["KnowledgeHub:Database:Password"] = PostgresContainerFixture.Password,
|
||||
|
||||
["KnowledgeHub:Paths:Attachments"] = _attachmentsDirectory,
|
||||
["KnowledgeHub:Paths:Imports"] = _importsDirectory,
|
||||
["KnowledgeHub:Paths:Backups"] = _backupsDirectory
|
||||
};
|
||||
|
||||
foreach (var (key, value) in _additionalConfiguration)
|
||||
{
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
configuration.AddInMemoryCollection(values);
|
||||
});
|
||||
}
|
||||
|
||||
private static DirectoryInfo FindRepositoryRoot()
|
||||
{
|
||||
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "global.json"))
|
||||
&& (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|
||||
|| File.Exists(Path.Combine(current.FullName, "PUBLIC_SOURCE_EXPORT.md"))))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the repository root.");
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Base for test classes that need an already-completed first-run setup (an owner
|
||||
/// account, in an isolated database, ready to log in). Each test class gets its own
|
||||
/// database and its own owner — never a shared mutable fixture between test classes.
|
||||
/// </summary>
|
||||
public abstract class OwnerSetupTestBase : IAsyncLifetime
|
||||
{
|
||||
protected OwnerSetupTestBase(
|
||||
PostgresContainerFixture postgres,
|
||||
IReadOnlyDictionary<string, string?>? additionalConfiguration = null)
|
||||
{
|
||||
Factory = new KnowledgeHubIntegrationFactory(postgres, additionalConfiguration);
|
||||
}
|
||||
|
||||
protected KnowledgeHubIntegrationFactory Factory { get; }
|
||||
|
||||
protected string OwnerEmail { get; } = "eigenaar@itworx.test";
|
||||
|
||||
protected string OwnerPassword { get; } = "correct-horse-battery-staple";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await ((IAsyncLifetime)Factory).InitializeAsync();
|
||||
|
||||
using var client = Factory.CreateClient();
|
||||
|
||||
var response = await SetupHelper.CompleteFirstRunSetupAsync(
|
||||
client,
|
||||
"ItWorx Testomgeving",
|
||||
"Eigenaar Testpersoon",
|
||||
OwnerEmail,
|
||||
OwnerPassword);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException($"First-run setup failed ({response.StatusCode}): {body}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await ((IAsyncLifetime)Factory).DisposeAsync();
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// One PostgreSQL Testcontainer for the whole assembly run (docs/22-testing-strategy.md
|
||||
/// — "One PostgreSQL Testcontainer per test collection/suite"). Individual test classes
|
||||
/// each get an isolated logical database on this shared server instance via
|
||||
/// <see cref="KnowledgeHubIntegrationFactory"/>, so tests never share mutable rows.
|
||||
/// </summary>
|
||||
public sealed class PostgresContainerFixture : IAsyncLifetime
|
||||
{
|
||||
private const string SharedNetworkAlias = "knowledge-hub-postgres";
|
||||
private static readonly string? SharedNetworkName =
|
||||
Environment.GetEnvironmentVariable("TESTCONTAINERS_NETWORK");
|
||||
|
||||
// Pinned to the same PostgreSQL major/minor family as production
|
||||
// (docker-compose.yml / docs/23-deployment-unraid.md).
|
||||
private readonly PostgreSqlContainer _container = BuildContainer();
|
||||
|
||||
public string Host => UsesSharedNetwork ? SharedNetworkAlias : _container.Hostname;
|
||||
|
||||
public int Port => UsesSharedNetwork ? 5432 : _container.GetMappedPublicPort(5432);
|
||||
|
||||
public string AdminConnectionString => new NpgsqlConnectionStringBuilder(_container.GetConnectionString())
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port
|
||||
}.ConnectionString;
|
||||
|
||||
public static string Username => "knowledge_hub";
|
||||
|
||||
public static string Password => "integration-test-only";
|
||||
|
||||
public Task InitializeAsync() => _container.StartAsync();
|
||||
|
||||
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
|
||||
|
||||
private static bool UsesSharedNetwork => !string.IsNullOrWhiteSpace(SharedNetworkName);
|
||||
|
||||
private static PostgreSqlContainer BuildContainer()
|
||||
{
|
||||
var builder = new PostgreSqlBuilder(
|
||||
"postgres:18.4-bookworm@sha256:882236b897e39051d2368c5ccc6cda944904723506b2dfc97f2a8f5bc9afa382")
|
||||
.WithDatabase("knowledge_hub_admin")
|
||||
.WithUsername("knowledge_hub")
|
||||
.WithPassword("integration-test-only");
|
||||
|
||||
if (UsesSharedNetwork)
|
||||
{
|
||||
builder = builder
|
||||
.WithNetwork(SharedNetworkName!)
|
||||
.WithNetworkAliases(SharedNetworkAlias);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PostgresTestGroup : ICollectionFixture<PostgresContainerFixture>
|
||||
{
|
||||
public const string Name = "Postgres";
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Auth;
|
||||
using ItWorx.KnowledgeHub.Contracts.Setup;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
public sealed record CsrfToken(string Value, string HeaderName);
|
||||
|
||||
/// <summary>Shared helpers so every test class does not re-implement the first-run
|
||||
/// setup and CSRF handshake used to reach an authenticated state.</summary>
|
||||
public static class SetupHelper
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
|
||||
public static async Task<CsrfToken> GetCsrfTokenAsync(HttpClient client)
|
||||
{
|
||||
var response = await client.GetFromJsonAsync<CsrfTokenResponse>("/api/v1/auth/csrf", JsonOptions)
|
||||
?? throw new InvalidOperationException("No CSRF token returned.");
|
||||
|
||||
return new CsrfToken(response.Token, response.HeaderName);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> CompleteFirstRunSetupAsync(
|
||||
HttpClient client,
|
||||
string workspaceName,
|
||||
string displayName,
|
||||
string email,
|
||||
string password)
|
||||
{
|
||||
var csrf = await GetCsrfTokenAsync(client);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/setup/complete")
|
||||
{
|
||||
Content = JsonContent.Create(
|
||||
new CompleteSetupRequest(workspaceName, displayName, email, password),
|
||||
options: JsonOptions)
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> LoginAsync(HttpClient client, string email, string password)
|
||||
{
|
||||
return await client.PostAsJsonAsync(
|
||||
"/api/v1/auth/login",
|
||||
new LoginRequest(email, password),
|
||||
JsonOptions);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostWithCsrfAsync<TBody>(
|
||||
HttpClient client,
|
||||
string path,
|
||||
TBody body)
|
||||
{
|
||||
var csrf = await GetCsrfTokenAsync(client);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = JsonContent.Create(body, options: JsonOptions)
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PatchWithCsrfAsync<TBody>(
|
||||
HttpClient client,
|
||||
string path,
|
||||
TBody body)
|
||||
{
|
||||
var csrf = await GetCsrfTokenAsync(client);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Patch, path)
|
||||
{
|
||||
Content = JsonContent.Create(body, options: JsonOptions)
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PutWithCsrfAsync<TBody>(
|
||||
HttpClient client,
|
||||
string path,
|
||||
TBody body,
|
||||
string? ifMatch = null)
|
||||
{
|
||||
var csrf = await GetCsrfTokenAsync(client);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, path)
|
||||
{
|
||||
Content = JsonContent.Create(body, options: JsonOptions)
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
if (ifMatch is not null)
|
||||
{
|
||||
request.Headers.Add("If-Match", $"\"{ifMatch}\"");
|
||||
}
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostWithCsrfAndIfMatchAsync<TBody>(
|
||||
HttpClient client,
|
||||
string path,
|
||||
TBody body,
|
||||
string ifMatch)
|
||||
{
|
||||
var csrf = await GetCsrfTokenAsync(client);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = JsonContent.Create(body, options: JsonOptions)
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
request.Headers.Add("If-Match", $"\"{ifMatch}\"");
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> DeleteWithCsrfAsync(HttpClient client, string path)
|
||||
{
|
||||
var csrf = await GetCsrfTokenAsync(client);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
public static string? GetETag(HttpResponseMessage response) =>
|
||||
response.Headers.ETag?.Tag.Trim('"');
|
||||
|
||||
public static async Task<HttpResponseMessage> PostMultipartWithCsrfAsync(
|
||||
HttpClient client,
|
||||
string path,
|
||||
byte[] fileBytes,
|
||||
string fileName,
|
||||
string contentType)
|
||||
{
|
||||
var csrf = await GetCsrfTokenAsync(client);
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(fileBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
||||
content.Add(fileContent, "file", fileName);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content };
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user