Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ Connection strings for each service in development are in each host's `appsettin
- **Intra-domain dependencies are real; inter-domain dependencies are always mocked.** Own database, cache, and outbox are started and seeded in `[OneTimeSetUp]`. Cross-domain HTTP calls and direct broker publishes are replaced with `MockHttpClientFactory` / `UseExpectedAzureServiceBusPublisher()`.
- Outbox assertion helpers are database-specific: `UseExpectedPostgresOutboxPublisher()` for Products; `UseExpectedSqlServerOutboxPublisher()` for Shopping. Do not use the SQL Server helper in Products tests.
- Mock downstream HTTP calls; do not assume live APIs.
- **Outbox test-coverage split — don't re-flag as a gap without checking both places**: `tests\CoreEx.Database.SqlServer.Test.Unit` / `tests\CoreEx.Database.Postgres.Test.Unit` cover library-level logic only (parameter extensions, error-code mapping, `DatabaseRecord`/`DatabaseCommand` behavior) against a live database via DbEx migrations in the sibling `*.Test.Console` project — they do **not** exercise `SqlServerOutboxPublisher`/`SqlServerOutboxRelay` or `PostgresOutboxPublisher`/`PostgresOutboxRelay` end-to-end. That coverage lives in `samples\tests\Contoso.Products.Test.Relay` (Postgres) and `samples\tests\Contoso.Shopping.Test.Relay` (SQL Server): both spin up the real relay host (`WithApiTester<...Relay.Program>`), publish real events via the outbox, let the live `*OutboxRelayHostedService` poll and relay them, and assert what actually lands on the Service Bus emulator (`Test.GetAndClearAzureServiceBusAsync`). Keep the two relay test projects in parity — a fix or new scenario added to one should be mirrored in the other.

### House Rules
- Code comments end with a period/full stop.
Expand Down
2 changes: 1 addition & 1 deletion .github/instructions/coreex-tooling.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ dotnet run -- script outbox <schema> <name> # transactional outbox table(s)

**Naming convention** (what `Script` produces, and what any hand-named file must match): `yyyyMMdd-HHmmss-<kebab-description>.{sql|pgsql}`.
- The leading segment is the **current UTC date *and* time** (`yyyyMMdd-HHmmss`) at the moment of creation — **not** a placeholder date (e.g. `20250101`) and **not** a per-day incrementing index (e.g. `000001`). The time component provides natural ordering and uniqueness without tracking indices.
- The entire filename is **kebab-lower-case** — all lowercase, words separated by hyphens (e.g. `20260603-142530-create-bar-employee.sql`, never `...-create-Bar-Employee.sql`).
- The entire filename is **kebab-lower-case** — all lowercase, words separated by hyphens (e.g. `20260603-142530-create-bar-employee.sql` for SQL Server, `20260603-142530-create-bar-employee.pgsql` for PostgreSQL — never `...-create-Bar-Employee.sql`). The extension follows the provider rule above; this section is only about the casing of the name itself.

> **Do not author a schema-create script.** The `coreex` template already ships the default schema-create migration, so the schema exists from the first `Migrate`. Never emit a `create-<schema>-schema` script unless the user **explicitly** asks for an additional schema.

Expand Down
1 change: 1 addition & 0 deletions CoreEx.Samples.Build.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"samples/tests/Contoso.Products.Test.Unit/Contoso.Products.Test.Unit.csproj",
"samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj",
"samples/tests/Contoso.Shopping.Test.Common/Contoso.Shopping.Test.Common.csproj",
"samples/tests/Contoso.Shopping.Test.Relay/Contoso.Shopping.Test.Relay.csproj",
"samples/tests/Contoso.Shopping.Test.Subscribe/Contoso.Shopping.Test.Subscribe.csproj",
"samples/tests/Contoso.Shopping.Test.Unit/Contoso.Shopping.Test.Unit.csproj"
]
Expand Down
1 change: 1 addition & 0 deletions CoreEx.Samples.Test.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"samples/tests/Contoso.Products.Test.Subscribe/Contoso.Products.Test.Subscribe.csproj",
"samples/tests/Contoso.Products.Test.Unit/Contoso.Products.Test.Unit.csproj",
"samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj",
"samples/tests/Contoso.Shopping.Test.Relay/Contoso.Shopping.Test.Relay.csproj",
"samples/tests/Contoso.Shopping.Test.Subscribe/Contoso.Shopping.Test.Subscribe.csproj",
"samples/tests/Contoso.Shopping.Test.Unit/Contoso.Shopping.Test.Unit.csproj"
]
Expand Down
17 changes: 16 additions & 1 deletion CoreEx.sln
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.8.11904.113 insiders
VisualStudioVersion = 18.8.11904.113
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "gen", "gen", "{21B44B34-1D47-1312-99D7-9BFB05A71085}"
EndProject
Expand Down Expand Up @@ -246,6 +246,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data.GraphQL", "src\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data.GraphQL.Test.Unit", "tests\CoreEx.Data.GraphQL.Test.Unit\CoreEx.Data.GraphQL.Test.Unit.csproj", "{831702B9-80AE-47D9-A0D5-012E9241A298}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Test.Relay", "samples\tests\Contoso.Shopping.Test.Relay\Contoso.Shopping.Test.Relay.csproj", "{F0782889-6889-4DF1-B025-06A92C0C4E11}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -1156,6 +1158,18 @@ Global
{831702B9-80AE-47D9-A0D5-012E9241A298}.Release|x64.Build.0 = Release|Any CPU
{831702B9-80AE-47D9-A0D5-012E9241A298}.Release|x86.ActiveCfg = Release|Any CPU
{831702B9-80AE-47D9-A0D5-012E9241A298}.Release|x86.Build.0 = Release|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|x64.ActiveCfg = Debug|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|x64.Build.0 = Debug|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|x86.ActiveCfg = Debug|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Debug|x86.Build.0 = Debug|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|Any CPU.Build.0 = Release|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x64.ActiveCfg = Release|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x64.Build.0 = Release|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x86.ActiveCfg = Release|Any CPU
{F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1215,6 +1229,7 @@ Global
{175C2EF4-9EBF-1E78-03BD-A1B569B79218} = {1EDEE56C-4E5B-5153-E988-38698733D7A4}
{2F4DF929-C4B2-4A11-E2A7-13E4EC355269} = {1EDEE56C-4E5B-5153-E988-38698733D7A4}
{60D956AA-3030-FB48-423C-F561A8157E30} = {1EDEE56C-4E5B-5153-E988-38698733D7A4}
{F0782889-6889-4DF1-B025-06A92C0C4E11} = {1EDEE56C-4E5B-5153-E988-38698733D7A4}
{FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1} = {A8732A47-07D4-8D47-C5B6-F97BD3E38958}
{88D83B9E-144B-54B9-421D-13C133018F24} = {FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1}
{D1281655-C259-3F9B-6488-B2410D6DF57F} = {FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1}
Expand Down
1 change: 1 addition & 0 deletions CoreEx.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
<Folder Name="/samples/Shopping/tests/">
<Project Path="samples/tests/Contoso.Shopping.Test.Api/Contoso.Shopping.Test.Api.csproj" />
<Project Path="samples/tests/Contoso.Shopping.Test.Common/Contoso.Shopping.Test.Common.csproj" />
<Project Path="samples/tests/Contoso.Shopping.Test.Relay/Contoso.Shopping.Test.Relay.csproj" />
<Project Path="samples/tests/Contoso.Shopping.Test.Subscribe/Contoso.Shopping.Test.Subscribe.csproj" />
<Project Path="samples/tests/Contoso.Shopping.Test.Unit/Contoso.Shopping.Test.Unit.csproj" />
</Folder>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model
e.Property(p => p.Price).HasColumnName("price").HasColumnType("NUMERIC(18, 2)");
e.Property(p => p.IsInactive).HasColumnName("is_inactive").HasColumnType("BOOLEAN");
e.Property(p => p.IsNonStocked).HasColumnName("is_non_stocked").HasColumnType("BOOLEAN");
e.Property(p => p.Tags).HasColumnName("tags_json").HasColumnType("JSONB").HasConversion(TypeToJsonStringEfConverter<List<string>?>.Default);
e.Property(p => p.Tags).HasColumnName("tags_json").HasColumnType("JSONB").HasConversion(TypeToJsonStringEfConverter<List<string>?>.Default, TypeToJsonStringEfComparer<List<string>?>.Default);
e.Property(p => p.CreatedBy).HasColumnName("created_by").HasColumnType("CHARACTER VARYING(250)");
e.Property(p => p.CreatedOn).HasColumnName("created_on").HasColumnType("TIMESTAMP WITH TIME ZONE");
e.Property(p => p.UpdatedBy).HasColumnName("updated_by").HasColumnType("CHARACTER VARYING(250)");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model
e.Property(p => p.DiscountCouponCode).HasColumnName("DiscountCouponCode").HasColumnType("NVARCHAR(50)");
e.Property(p => p.DiscountAmount).HasColumnName("DiscountAmount").HasColumnType("DECIMAL(18, 2)");
e.Property(p => p.Total).HasColumnName("Total").HasColumnType("DECIMAL(18, 2)");
e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(2000)").HasConversion(TypeToJsonStringEfConverter<Persistence.Address?>.Default);
e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(2000)").HasConversion(TypeToJsonStringEfConverter<Persistence.Address?>.Default, TypeToJsonStringEfComparer<Persistence.Address?>.Default);
e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)");
e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET");
e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="UnitTestEx.NUnit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\CoreEx.UnitTesting\CoreEx.UnitTesting.csproj" />
<ProjectReference Include="..\..\src\Contoso.Shopping.Relay\Contoso.Shopping.Relay.csproj" />
<ProjectReference Include="..\Contoso.Shopping.Test.Common\Contoso.Shopping.Test.Common.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="appsettings.unittest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Resources\**\*" />
</ItemGroup>

</Project>
13 changes: 13 additions & 0 deletions samples/tests/Contoso.Shopping.Test.Relay/GlobalUsing.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
global using Contoso.Shopping.Infrastructure.Repositories;
global using CoreEx.Azure.Messaging.ServiceBus;
global using CoreEx.Events;
global using CoreEx.UnitTesting;
global using AwesomeAssertions;
global using Microsoft.Extensions.DependencyInjection;
global using NUnit.Framework;
global using System.Net;
global using UnitTestEx;
global using UnitTestEx.Expectations;
global using DbMigration = Contoso.Shopping.Database.Program;
global using ExecutionContext = CoreEx.ExecutionContext;
global using TestData = Contoso.Shopping.Test.Common.TestData;
41 changes: 41 additions & 0 deletions samples/tests/Contoso.Shopping.Test.Relay/OtherTests.Health.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace Contoso.Shopping.Test.Relay;

public partial class OtherTests
{
[TestCase("/health/live")]
[TestCase("/health/startup")]
[TestCase("/health/ready")]
public void Health(string path)
{
Test.Http()
.Run(HttpMethod.Get, path)
.Response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable);
}

[TestCase("/health/live/detailed", true)]
[TestCase("/health/startup/detailed", false)]
[TestCase("/health/ready/detailed", false)]
public void Health_Detailed(string path, bool minimal)
{
string[] requiredServices =
[
"sqlServer",
"sqlserver-outbox-relay-00",
"sqlserver-outbox-relay-01",
"sqlserver-outbox-relay-02",
"sqlserver-outbox-relay-03"
];

var r = Test.Http()
.Run(HttpMethod.Get, path)
.AssertContentTypeJson();

r.Response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable);

var json = r.GetContent();
if (minimal)
json.Should().NotContainAny(requiredServices);
else
json.Should().ContainAll(requiredServices);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Contoso.Shopping.Test.Relay;

public partial class OtherTests
{
[Test]
public void HostedService_Pause_And_Resume()
{
var s = Test.Http<string>()
.Run(HttpMethod.Get, "/hosted-services/sqlserver-outbox-relay-03/status")
.Value;

s.Should().BeOneOf("Running", "Sleeping");

Test.Http()
.Run(HttpMethod.Post, "/hosted-services/sqlserver-outbox-relay-03/pause")
.Response.StatusCode.Should().Be(HttpStatusCode.Accepted);

s = Test.Delay(TimeSpan.FromSeconds(1))
.Http<string>()
.Run(HttpMethod.Get, "/hosted-services/sqlserver-outbox-relay-03/status")
.Value;

s.Should().Be("Paused");

Test.Http()
.Run(HttpMethod.Post, "/hosted-services/sqlserver-outbox-relay-03/resume")
.Response.StatusCode.Should().Be(HttpStatusCode.Accepted);

s = Test.Delay(TimeSpan.FromSeconds(1))
.Http<string>()
.Run(HttpMethod.Get, "/hosted-services/sqlserver-outbox-relay-03/status")
.Value;

s.Should().BeOneOf("Running", "Sleeping");
}
}
10 changes: 10 additions & 0 deletions samples/tests/Contoso.Shopping.Test.Relay/OtherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Contoso.Shopping.Test.Relay;

public partial class OtherTests : WithApiTester<Contoso.Shopping.Relay.Program>
{
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
await Test.MigrateSqlServerDataAsync<TestData>(["no-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false);
}
}
46 changes: 46 additions & 0 deletions samples/tests/Contoso.Shopping.Test.Relay/RelayTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using CoreEx.Database.SqlServer.Outbox;

namespace Contoso.Shopping.Test.Relay;

public class RelayTests : WithApiTester<Contoso.Shopping.Relay.Program>
{
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
await Test.MigrateSqlServerDataAsync<TestData>(["no-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false);
await Test.GetAndClearAzureServiceBusAsync(ServiceBusSessionReceiverOptions.CreateForTopicSubscription("contoso", "shopping"));
}

[Test]
public void Outbox_Relay()
{
// Arrange the two events to publish and relay.
var ce1 = Test.CreateCloudEventFromJsonResource("BasketCreatedCloudEvent.json");
var ce2 = Test.CreateCloudEventFromJsonResource("BasketUpdatedCloudEvent.json");

// Publish two events to the outbox.
Test.ScopedType<ExecutionContext>(test =>
{
test.Run(async _ =>
{
// Publish two events to the outbox.
var pub = ActivatorUtilities.GetServiceOrCreateInstance<SqlServerOutboxPublisher>(test.Services);
pub.Add("contoso", [ce1, ce2]);
await pub.PublishAsync();

// Hosted-service(s) are currently running and should relay to Azure Service Bus; we just need to give it a few seconds to do so.
for (int i = 0; i < 5; i++)
await Task.Delay(TimeSpan.FromSeconds(1));

// Receive the events from Azure Service Bus and assert.
var list = await Test.GetAndClearAzureServiceBusAsync(ServiceBusSessionReceiverOptions.CreateForTopicSubscription("contoso", "shopping"));

list.Should().NotBeNull().And.HaveCount(2);
var ce1Msg = list.Should().ContainSingle(x => x.MessageId == ce1.Id).Subject;
var ce2Msg = list.Should().ContainSingle(x => x.MessageId == ce2.Id).Subject;
ObjectComparer.AssertJson(ce1.EncodeToJsonElement().ToString(), ce1Msg.Body.ToString());
ObjectComparer.AssertJson(ce2.EncodeToJsonElement().ToString(), ce2Msg.Body.ToString());
}).AssertSuccess();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"specversion": "1.0",
"id": "019c72b9-e8e4-73aa-83fc-c5847cd4c218",
"type": "contoso.shopping.basket.created.v1",
"source": "urn:contoso:shopping",
"subject": "019c72b9-e8d8-7d33-821b-3d18af870885",
"time": "2026-02-18T21:48:32.3532188Z",
"partitionkey": "019c72b9-e8d8-7d33-821b-3d18af870885",
"authtype": "user",
"authid": "DOMAIN-CORP\\eric.sibly",
"dataschemaversion": "1.0",
"datacontenttype": "application/json",
"traceparent": "00-7de3f6abfabf606dd289d93e6d118457-cc3d1971a8b15a05-01",
"data": {
"changeLog": {
"createdBy": "DOMAIN-CORP\\eric.sibly",
"createdOn": "2026-02-18T21:48:32.3532188+00:00"
},
"id": "019c72b9-e8d8-7d33-821b-3d18af870885",
"customerId": "019c72b9-e8d8-7d33-821b-3d18af870886",
"statusCode": "ACTIVE"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"specversion": "1.0",
"id": "019c72b9-e9ab-7a00-97e9-cdf97f2f6025",
"type": "contoso.shopping.basket.updated.v1",
"source": "urn:contoso:shopping",
"subject": "019c72b9-e8d8-7d33-821b-3d18af870885",
"time": "2026-02-18T21:48:32.5446661Z",
"partitionkey": "019c72b9-e8d8-7d33-821b-3d18af870885",
"authtype": "user",
"authid": "DOMAIN-CORP\\eric.sibly",
"dataschemaversion": "1.0",
"datacontenttype": "application/json",
"traceparent": "00-1bd9e9901c03640e349802f6ce9df85f-e49a3299dd102aab-01",
"data": {
"changeLog": {
"createdBy": "DOMAIN-CORP\\eric.sibly",
"createdOn": "2026-02-18T21:48:32.3532188+00:00",
"updatedBy": "DOMAIN-CORP\\eric.sibly",
"updatedOn": "2026-02-18T21:48:32.5446661+00:00"
},
"id": "019c72b9-e8d8-7d33-821b-3d18af870885",
"customerId": "019c72b9-e8d8-7d33-821b-3d18af870886",
"statusCode": "ACTIVE"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information",
"ZiggyCreature": "Warning",
"StackExchange": "Warning"
}
},
"CoreEx": {
"Host": {
"Services": {
"Interval": "00:00:01"
}
}
},
"Aspire": {
"Azure": {
"Messaging": {
"ServiceBus": {
"ConnectionString": "Endpoint=sb://127.0.0.1;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;",
"ClientOptions": {
"RetryOptions": {
"MaxRetries": 0,
"Delay": "00:00:01",
"MaxDelay": "00:00:01",
"Mode": "Fixed",
"TryTimeout": "00:00:01"
}
}
}
}
}
}
}
Loading
Loading