Search/AdvisoryAI and DAL conversion to EF finishes up. Preparation for microservices consolidation.

This commit is contained in:
master
2026-02-25 18:19:22 +02:00
parent 4db038123b
commit 63c70a6d37
447 changed files with 52257 additions and 2636 deletions

View File

@@ -120,13 +120,8 @@ app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = Dat
.WithDescription("Returns the liveness status and current UTC timestamp for the Integration Catalog service. Used by the Router gateway and container orchestrator for health polling.")
.AllowAnonymous();
// Ensure database is created (dev only)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<IntegrationDbContext>();
await dbContext.Database.EnsureCreatedAsync();
}
// Database schema created by SQL migrations (001_initial_schema.sql)
// Run via: stella-ops migration run Integrations --category startup
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.LoadTranslationsAsync();

View File

@@ -0,0 +1,9 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using StellaOps.Integrations.Persistence;
using StellaOps.Integrations.Persistence.EfCore.CompiledModels;
#pragma warning disable 219, 612, 618
#nullable disable
[assembly: DbContextModel(typeof(IntegrationDbContext), typeof(IntegrationDbContextModel))]

View File

@@ -0,0 +1,47 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Integrations.Persistence.EfCore.CompiledModels
{
[DbContext(typeof(IntegrationDbContext))]
public partial class IntegrationDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static IntegrationDbContextModel()
{
var model = new IntegrationDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (IntegrationDbContextModel)model.FinalizeModel();
}
private static IntegrationDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,30 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Integrations.Persistence.EfCore.CompiledModels
{
public partial class IntegrationDbContextModel
{
private IntegrationDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("a127631f-ea21-49e7-ba49-1169d76b0980"), entityTypeCount: 1)
{
}
partial void Initialize()
{
var integrationEntity = IntegrationEntityEntityType.Create(this);
IntegrationEntityEntityType.CreateAnnotations(integrationEntity);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,246 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Integrations.Core;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Integrations.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class IntegrationEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Integrations.Persistence.IntegrationEntity",
typeof(IntegrationEntity),
baseEntityType,
propertyCount: 19,
unnamedIndexCount: 5,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(IntegrationEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var authRefUri = runtimeEntityType.AddProperty(
"AuthRefUri",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("AuthRefUri", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<AuthRefUri>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true,
maxLength: 1024);
authRefUri.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
authRefUri.AddAnnotation("Relational:ColumnName", "auth_ref_uri");
var configJson = runtimeEntityType.AddProperty(
"ConfigJson",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("ConfigJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<ConfigJson>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
configJson.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
configJson.AddAnnotation("Relational:ColumnName", "config_json");
configJson.AddAnnotation("Relational:ColumnType", "jsonb");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(IntegrationEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
var createdBy = runtimeEntityType.AddProperty(
"CreatedBy",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("CreatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<CreatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true,
maxLength: 256);
createdBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdBy.AddAnnotation("Relational:ColumnName", "created_by");
var description = runtimeEntityType.AddProperty(
"Description",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true,
maxLength: 1024);
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
description.AddAnnotation("Relational:ColumnName", "description");
var endpoint = runtimeEntityType.AddProperty(
"Endpoint",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("Endpoint", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Endpoint>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
maxLength: 2048);
endpoint.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
endpoint.AddAnnotation("Relational:ColumnName", "endpoint");
var isDeleted = runtimeEntityType.AddProperty(
"IsDeleted",
typeof(bool),
propertyInfo: typeof(IntegrationEntity).GetProperty("IsDeleted", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<IsDeleted>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: false);
isDeleted.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
isDeleted.AddAnnotation("Relational:ColumnName", "is_deleted");
var lastHealthCheckAt = runtimeEntityType.AddProperty(
"LastHealthCheckAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(IntegrationEntity).GetProperty("LastHealthCheckAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<LastHealthCheckAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
lastHealthCheckAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lastHealthCheckAt.AddAnnotation("Relational:ColumnName", "last_health_check_at");
var lastHealthStatus = runtimeEntityType.AddProperty(
"LastHealthStatus",
typeof(HealthStatus),
propertyInfo: typeof(IntegrationEntity).GetProperty("LastHealthStatus", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<LastHealthStatus>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
lastHealthStatus.SetSentinelFromProviderValue(0);
lastHealthStatus.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lastHealthStatus.AddAnnotation("Relational:ColumnName", "last_health_status");
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
maxLength: 256);
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
name.AddAnnotation("Relational:ColumnName", "name");
var organizationId = runtimeEntityType.AddProperty(
"OrganizationId",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("OrganizationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<OrganizationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true,
maxLength: 256);
organizationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
organizationId.AddAnnotation("Relational:ColumnName", "organization_id");
var provider = runtimeEntityType.AddProperty(
"Provider",
typeof(IntegrationProvider),
propertyInfo: typeof(IntegrationEntity).GetProperty("Provider", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Provider>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
provider.SetSentinelFromProviderValue(0);
provider.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
provider.AddAnnotation("Relational:ColumnName", "provider");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(IntegrationStatus),
propertyInfo: typeof(IntegrationEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
status.SetSentinelFromProviderValue(0);
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
var tagsJson = runtimeEntityType.AddProperty(
"TagsJson",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("TagsJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<TagsJson>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
tagsJson.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tagsJson.AddAnnotation("Relational:ColumnName", "tags");
tagsJson.AddAnnotation("Relational:ColumnType", "jsonb");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true,
maxLength: 128);
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var type = runtimeEntityType.AddProperty(
"Type",
typeof(IntegrationType),
propertyInfo: typeof(IntegrationEntity).GetProperty("Type", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Type>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
type.SetSentinelFromProviderValue(0);
type.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
type.AddAnnotation("Relational:ColumnName", "type");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(IntegrationEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
var updatedBy = runtimeEntityType.AddProperty(
"UpdatedBy",
typeof(string),
propertyInfo: typeof(IntegrationEntity).GetProperty("UpdatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<UpdatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true,
maxLength: 256);
updatedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
var index = runtimeEntityType.AddIndex(
new[] { provider });
var index0 = runtimeEntityType.AddIndex(
new[] { status });
var index1 = runtimeEntityType.AddIndex(
new[] { tenantId });
var index2 = runtimeEntityType.AddIndex(
new[] { type });
var index3 = runtimeEntityType.AddIndex(
new[] { tenantId, name },
unique: true);
index3.AddAnnotation("Relational:Filter", "is_deleted = false");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "integrations");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "integrations");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Integrations.Persistence.EfCore.Context;
/// <summary>
/// Design-time DbContext factory for dotnet ef CLI tooling.
/// Used by scaffold and optimize commands.
/// </summary>
public sealed class IntegrationDesignTimeDbContextFactory : IDesignTimeDbContextFactory<IntegrationDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=integrations,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_INTEGRATIONS_EF_CONNECTION";
public IntegrationDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<IntegrationDbContext>()
.UseNpgsql(connectionString)
.Options;
return new IntegrationDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -5,21 +5,31 @@ namespace StellaOps.Integrations.Persistence;
/// <summary>
/// EF Core DbContext for Integration persistence.
/// SQL migrations remain authoritative; EF models are scaffolded FROM schema.
/// </summary>
public sealed class IntegrationDbContext : DbContext
public partial class IntegrationDbContext : DbContext
{
public IntegrationDbContext(DbContextOptions<IntegrationDbContext> options)
public const string DefaultSchemaName = "integrations";
private readonly string _schemaName;
public IntegrationDbContext(DbContextOptions<IntegrationDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? DefaultSchemaName
: schemaName.Trim();
}
public DbSet<IntegrationEntity> Integrations => Set<IntegrationEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
modelBuilder.Entity<IntegrationEntity>(entity =>
{
entity.ToTable("integrations");
entity.ToTable("integrations", schemaName);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
@@ -53,7 +63,11 @@ public sealed class IntegrationDbContext : DbContext
entity.HasIndex(e => e.TenantId);
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique().HasFilter("is_deleted = false");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
/// <summary>

View File

@@ -0,0 +1,44 @@
-- ============================================================================
-- Integrations — Initial Schema
-- Extracted from IntegrationDbContext OnModelCreating (EF code-first → raw SQL)
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS integrations;
CREATE TABLE IF NOT EXISTS integrations.integrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(256) NOT NULL,
description VARCHAR(1024),
type TEXT NOT NULL,
provider TEXT NOT NULL,
status TEXT NOT NULL,
endpoint VARCHAR(2048) NOT NULL,
auth_ref_uri VARCHAR(1024),
organization_id VARCHAR(256),
config_json JSONB,
last_health_status TEXT,
last_health_check_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by VARCHAR(256),
updated_by VARCHAR(256),
tenant_id VARCHAR(128),
tags JSONB,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS ix_integrations_type
ON integrations.integrations (type);
CREATE INDEX IF NOT EXISTS ix_integrations_provider
ON integrations.integrations (provider);
CREATE INDEX IF NOT EXISTS ix_integrations_status
ON integrations.integrations (status);
CREATE INDEX IF NOT EXISTS ix_integrations_tenant_id
ON integrations.integrations (tenant_id);
CREATE UNIQUE INDEX IF NOT EXISTS ix_integrations_tenant_name
ON integrations.integrations (tenant_id, name)
WHERE is_deleted = false;

View File

@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Integrations.Persistence.EfCore.CompiledModels;
namespace StellaOps.Integrations.Persistence.Postgres;
internal static class IntegrationDbContextFactory
{
public static IntegrationDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? IntegrationDbContext.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<IntegrationDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, IntegrationDbContext.DefaultSchemaName, StringComparison.Ordinal))
{
optionsBuilder.UseModel(IntegrationDbContextModel.Instance);
}
return new IntegrationDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -9,11 +9,26 @@
<RootNamespace>StellaOps.Integrations.Persistence</RootNamespace>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Integrations.Persistence.Tests" />
</ItemGroup>
<ItemGroup>
<!-- Embed SQL migrations as resources -->
<EmbeddedResource Include="Migrations\**\*.sql" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\IntegrationDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Integrations.Core\StellaOps.Integrations.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>

View File

@@ -0,0 +1,55 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using StellaOps.Integrations.Persistence;
using StellaOps.Integrations.Persistence.EfCore.CompiledModels;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Integrations.Tests;
/// <summary>
/// Guard tests ensuring the EF Core compiled model is real (not a stub)
/// and contains all expected entity type registrations.
/// </summary>
public sealed class CompiledModelGuardTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompiledModel_Instance_IsNotNull()
{
IntegrationDbContextModel.Instance.Should().NotBeNull(
"compiled model must be generated via 'dotnet ef dbcontext optimize', not a stub");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompiledModel_HasExpectedEntityTypeCount()
{
var entityTypes = IntegrationDbContextModel.Instance.GetEntityTypes().ToList();
entityTypes.Should().HaveCount(1,
"integration compiled model must contain exactly 1 entity type (regenerate with 'dotnet ef dbcontext optimize' if count differs)");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(typeof(IntegrationEntity))]
public void CompiledModel_ContainsEntityType(Type entityType)
{
var found = IntegrationDbContextModel.Instance.FindEntityType(entityType);
found.Should().NotBeNull(
$"compiled model must contain entity type '{entityType.Name}' — regenerate if missing");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompiledModel_EntityTypes_HaveTableNames()
{
var entityTypes = IntegrationDbContextModel.Instance.GetEntityTypes();
foreach (var entityType in entityTypes)
{
var tableName = entityType.GetTableName();
tableName.Should().NotBeNullOrWhiteSpace(
$"entity type '{entityType.ClrType.Name}' must have a table name configured");
}
}
}

View File

@@ -1,10 +1,18 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using StellaOps.Integrations.Persistence;
@@ -91,14 +99,63 @@ public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFacto
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://integrations-test.local",
["Authority:ResourceServer:RequireHttpsMetadata"] = "false",
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IIntegrationRepository>();
services.AddSingleton<IIntegrationRepository, InMemoryIntegrationRepository>();
services.RemoveAll<IHostedService>();
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = IntegrationTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = IntegrationTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, IntegrationTestAuthHandler>(
IntegrationTestAuthHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, IntegrationTestAuthHandler>(
StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
});
}
}
internal sealed class IntegrationTestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
internal const string SchemeName = "IntegrationTest";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "test-user"),
new Claim(StellaOpsClaimTypes.Scope, StellaOpsScopes.IntegrationRead),
new Claim(StellaOpsClaimTypes.Scope, StellaOpsScopes.IntegrationWrite),
new Claim(StellaOpsClaimTypes.Scope, StellaOpsScopes.IntegrationOperate),
new Claim(StellaOpsClaimTypes.Tenant, "test-tenant"),
};
var identity = new ClaimsIdentity(claims, StellaOpsAuthenticationDefaults.AuthenticationType);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
internal sealed class InMemoryIntegrationRepository : IIntegrationRepository
{
private readonly Dictionary<Guid, Integration> _items = new();