M0: scaffold solution, EF+Timescale schema, /healthz
Five-project Clean Architecture solution (Core/Infrastructure/App + Core.Tests/ Integration.Tests) on .NET 10 with central package management, snake_case EF mapping, and shared build/style config. - Full domain entity set + EF DbContext for the SDD §5.3 schema (singular table names). - InitialSchema migration (relational) + TimescaleHypertables migration (raw SQL: create_hypertable + compression on reading, hypertable on consumption). - App wiring: Serilog (actually wired, unlike MQTTower), DbContext, migrate-on-startup, /healthz. Serves plain HTTP behind a reverse proxy (no HTTPS redirect). - deploy/Dockerfile (2-stage, ICU-capable) + docker-compose (app + timescaledb). - Integration.Tests: shared TimescaleFixture (Testcontainers) — migrations, hypertables, compression policy, and /healthz all verified green (4/4). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
[Collection("Timescale")]
|
||||
public sealed class HealthEndpointTests(TimescaleFixture fx)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Healthz_returns_ok()
|
||||
{
|
||||
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync(new Uri("/healthz", UriKind.Relative));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("ok", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="Respawn" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\App\MeterVault.App.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Boots the real ASP.NET Core app in-memory against the shared Timescale container.
|
||||
/// Migrations are already applied by <see cref="TimescaleFixture"/>, so startup migration is off.
|
||||
/// </summary>
|
||||
public sealed class MeterVaultAppFactory(string connectionString) : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.UseSetting("ConnectionStrings:Default", connectionString);
|
||||
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
[Collection("Timescale")]
|
||||
public sealed class SchemaTests(TimescaleFixture fx)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Migrations_are_applied()
|
||||
{
|
||||
await using var ctx = fx.CreateContext();
|
||||
var applied = (await ctx.Database.GetAppliedMigrationsAsync()).ToList();
|
||||
|
||||
Assert.Contains(applied, m => m.EndsWith("InitialSchema", StringComparison.Ordinal));
|
||||
Assert.Contains(applied, m => m.EndsWith("TimescaleHypertables", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_and_consumption_are_hypertables()
|
||||
{
|
||||
await using var ctx = fx.CreateContext();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
await conn.OpenAsync();
|
||||
|
||||
var names = await QueryStringsAsync(conn,
|
||||
"SELECT hypertable_name FROM timescaledb_information.hypertables ORDER BY hypertable_name;");
|
||||
|
||||
Assert.Equal(["consumption", "reading"], names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_has_a_compression_policy()
|
||||
{
|
||||
await using var ctx = fx.CreateContext();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
await conn.OpenAsync();
|
||||
|
||||
// A compression/columnstore policy shows up as a scheduled job against the hypertable.
|
||||
var jobs = await QueryStringsAsync(conn,
|
||||
"SELECT hypertable_name FROM timescaledb_information.jobs " +
|
||||
"WHERE proc_name IN ('policy_compression', 'policy_columnstore') AND hypertable_name = 'reading';");
|
||||
|
||||
Assert.Contains("reading", jobs);
|
||||
}
|
||||
|
||||
private static async Task<List<string>> QueryStringsAsync(DbConnection conn, string sql)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var results = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up exactly one TimescaleDB container per test run (shared via the "Timescale"
|
||||
/// collection) and applies migrations once. Tests reuse it and isolate themselves with Respawn.
|
||||
/// The image tag is pinned: the compression DDL (add_compression_policy) was renamed toward
|
||||
/// add_columnstore_policy in newer Timescale, so a floating tag would risk breaking migrations.
|
||||
/// </summary>
|
||||
public sealed class TimescaleFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder("timescale/timescaledb:2.17.2-pg16")
|
||||
.WithDatabase("metervault")
|
||||
.WithUsername("metervault")
|
||||
.WithPassword("metervault")
|
||||
.Build();
|
||||
|
||||
public string ConnectionString => _db.GetConnectionString();
|
||||
|
||||
public MeterVaultDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MeterVaultDbContext>()
|
||||
.UseNpgsql(ConnectionString, npgsql =>
|
||||
npgsql.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
return new MeterVaultDbContext(options);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _db.StartAsync();
|
||||
await using var ctx = CreateContext();
|
||||
await ctx.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync() => await _db.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Binds the shared <see cref="TimescaleFixture"/> to all tests in the "Timescale" collection.</summary>
|
||||
[CollectionDefinition("Timescale")]
|
||||
public sealed class TimescaleCollection : ICollectionFixture<TimescaleFixture>;
|
||||
Reference in New Issue
Block a user