From ca598e5da0c2f8df4f5a8ca542b3641c85792d74 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 1 Jul 2026 16:45:29 -0700 Subject: [PATCH 1/2] feat: add coreex-test-api, coreex-test-subscribe, coreex-test-relay skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three integration-test skills covering the CoreEx test project types that had no first-class skill yet: - coreex-test-api: canonical *.Test.Api workflow (seed data, per-entity read/mutate test classes, CRUD/ETag/soft-delete/idempotent-delete scenarios, provider-specific outbox assertions, inter-domain HTTP mocking, resource-based JSON assertions). Owns the shared DB/cache/outbox setup foundations. - coreex-test-subscribe: *.Test.Subscribe workflow (simulating broker message receipt via ServiceBusSubscribedSubscriber, ErrorHandler outcome assertions, command / event-data-sync / event-business-process test shapes). Links back to coreex-test-api for the shared setup mechanics rather than duplicating it. - coreex-test-relay: lightweight *.Test.Relay skill (relay tests are largely templated plumbing and rarely extended per domain) covering the outbox forwarding pattern, hosted-service pause/resume checks, and the Service Bus emulator troubleshooting note. Also: - Extracted ~154 lines of duplicated Subscribe-test guidance out of coreex-subscriber/references/workflow.md into coreex-test-subscribe, leaving a pointer behind (single source of truth per the tests instructions' "do not duplicate" rule). - Repointed coreex-api and coreex-subscriber to hand off to the new test skills instead of dead-ending at coreex-tests.instructions.md directly. - Added a light hand-off pointer from solution-scaffolder. - Wired all three new skills + their prompt bridge files into CoreEx.Template.csproj (both the CoreEx.Bootstrap _AiFile set and the CoreEx.Ai per-skill Copy blocks), verified via a full dotnet build + nupkg content inspection. All three skills are domain-agnostic (generic {Entity}/{Domain} placeholders, consistent with existing skills) — sample links are kept only as illustrative Key References, matching every other skill in the repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/prompts/coreex-test-api.prompt.md | 12 + .github/prompts/coreex-test-relay.prompt.md | 11 + .../prompts/coreex-test-subscribe.prompt.md | 13 + .github/skills/coreex-api/SKILL.md | 6 +- .github/skills/coreex-subscriber/SKILL.md | 7 +- .../coreex-subscriber/references/workflow.md | 154 +---------- .github/skills/coreex-test-api/SKILL.md | 53 ++++ .../coreex-test-api/references/workflow.md | 240 ++++++++++++++++++ .github/skills/coreex-test-relay/SKILL.md | 89 +++++++ .github/skills/coreex-test-subscribe/SKILL.md | 50 ++++ .../references/workflow.md | 193 ++++++++++++++ .github/skills/solution-scaffolder/SKILL.md | 1 + src/CoreEx.Template/CoreEx.Template.csproj | 17 ++ 13 files changed, 692 insertions(+), 154 deletions(-) create mode 100644 .github/prompts/coreex-test-api.prompt.md create mode 100644 .github/prompts/coreex-test-relay.prompt.md create mode 100644 .github/prompts/coreex-test-subscribe.prompt.md create mode 100644 .github/skills/coreex-test-api/SKILL.md create mode 100644 .github/skills/coreex-test-api/references/workflow.md create mode 100644 .github/skills/coreex-test-relay/SKILL.md create mode 100644 .github/skills/coreex-test-subscribe/SKILL.md create mode 100644 .github/skills/coreex-test-subscribe/references/workflow.md diff --git a/.github/prompts/coreex-test-api.prompt.md b/.github/prompts/coreex-test-api.prompt.md new file mode 100644 index 00000000..c7d94b74 --- /dev/null +++ b/.github/prompts/coreex-test-api.prompt.md @@ -0,0 +1,12 @@ +--- +mode: agent +description: "Write or update an integration test in a CoreEx *.Test.Api project — OneTimeSetUp seeding, CRUD/ETag/soft-delete scenarios, outbox assertions, and HTTP client mocking." +--- + +Use the `coreex-test-api` skill to write or update an integration test in this CoreEx `*.Test.Api` project. + +Read `.github/skills/coreex-test-api/SKILL.md` first, then follow +`.github/skills/coreex-test-api/references/workflow.md` step by step (seed data → test classes → +per-operation patterns → outbox assertions → HTTP mocking → resources). + +User request: ${input} diff --git a/.github/prompts/coreex-test-relay.prompt.md b/.github/prompts/coreex-test-relay.prompt.md new file mode 100644 index 00000000..be0407b0 --- /dev/null +++ b/.github/prompts/coreex-test-relay.prompt.md @@ -0,0 +1,11 @@ +--- +mode: agent +description: "Understand, verify, or extend an Outbox Relay host integration test in a CoreEx *.Test.Relay project — mostly templated plumbing checks, rarely extended per domain." +--- + +Use the `coreex-test-relay` skill to work with the CoreEx `*.Test.Relay` project. + +Read `.github/skills/coreex-test-relay/SKILL.md` first — check what the templated `RelayTests.cs` +already verifies before assuming new scenarios are needed. + +User request: ${input} diff --git a/.github/prompts/coreex-test-subscribe.prompt.md b/.github/prompts/coreex-test-subscribe.prompt.md new file mode 100644 index 00000000..b31be24e --- /dev/null +++ b/.github/prompts/coreex-test-subscribe.prompt.md @@ -0,0 +1,13 @@ +--- +mode: agent +description: "Write or update an integration test in a CoreEx *.Test.Subscribe project — simulating broker message receipt, ErrorHandler outcomes, and command/event-sync/event-business-process test scenarios." +--- + +Use the `coreex-test-subscribe` skill to write or update an integration test in this CoreEx +`*.Test.Subscribe` project. + +Read `.github/skills/coreex-test-subscribe/SKILL.md` first, then follow +`.github/skills/coreex-test-subscribe/references/workflow.md` step by step. For the shared DB/cache/ +outbox `OneTimeSetUp` mechanics, also see `.github/skills/coreex-test-api/references/workflow.md`. + +User request: ${input} diff --git a/.github/skills/coreex-api/SKILL.md b/.github/skills/coreex-api/SKILL.md index 99558190..c02fc723 100644 --- a/.github/skills/coreex-api/SKILL.md +++ b/.github/skills/coreex-api/SKILL.md @@ -1,6 +1,6 @@ --- name: coreex-api -description: "Add or modify a CoreEx API controller (or Minimal API endpoint) in an *.Api host. USE FOR: scaffolding the MVC controller pair (XxxController + XxxReadController), GET/query/schema endpoints, POST create, PUT + PATCH full-entity update, DELETE, and custom business-action endpoints. Covers both exception-based and Result service styles, and Minimal API as an alternative to MVC. DO NOT USE FOR: Api host setup / Program.cs (use coreex-host-setup), application services (use coreex-app-service), API integration tests (see coreex-tests.instructions.md)." +description: "Add or modify a CoreEx API controller (or Minimal API endpoint) in an *.Api host. USE FOR: scaffolding the MVC controller pair (XxxController + XxxReadController), GET/query/schema endpoints, POST create, PUT + PATCH full-entity update, DELETE, and custom business-action endpoints. Covers both exception-based and Result service styles, and Minimal API as an alternative to MVC. DO NOT USE FOR: Api host setup / Program.cs (use coreex-host-setup), application services (use coreex-app-service), API integration tests (use coreex-test-api)." argument-hint: "Optional: entity name, operations needed (get/query/create/update/delete/custom), exception-based or Result service style, MVC or Minimal API" tags: ["api", "controller", "mvc", "minimal-api", "webapi", "routing", "cqrs", "coreex"] --- @@ -23,7 +23,7 @@ Guides you through adding or modifying HTTP API endpoints in an `*.Api` host. Co - Api host setup and `Program.cs` composition — use `coreex-host-setup` - Application service creation — use `coreex-app-service` -- API integration tests (`WithApiTester`) — see `coreex-tests.instructions.md` +- API integration tests — use `coreex-test-api` (hand off once the endpoint is implemented) - Subscriber or relay hosts — controllers do not belong there ## Quick Reference @@ -49,6 +49,7 @@ Guides you through adding or modifying HTTP API endpoints in an `*.Api` host. Co - `Result` service → `WithResult` variants (`GetWithResultAsync`, `PostWithResultAsync`, `PutWithResultAsync`, …) - No business logic in controllers — delegate immediately to the application service - `[Query(supportsOrderBy: true), Paging(supportsCount: true)]` + `[HttpGet("$query")]` schema endpoint for query operations +- Once the endpoint is implemented, hand off to `coreex-test-api` to add/update its integration test For full workflow and code examples see [`references/workflow.md`](references/workflow.md). @@ -58,3 +59,4 @@ For full workflow and code examples see [`references/workflow.md`](references/wo - [`/samples/src/Contoso.Products.Api/Controllers/`](/samples/src/Contoso.Products.Api/Controllers/) — `ProductController` + `ProductReadController` (exception-based, full CRUD + query + $query) - [`/samples/src/Contoso.Shopping.Api/Controllers/`](/samples/src/Contoso.Shopping.Api/Controllers/) — `BasketController` + `BasketReadController` (Result<T> style, custom business actions, cross-tagged nested route) - [`/samples/src/Contoso.Orders.Api/Controllers/`](/samples/src/Contoso.Orders.Api/Controllers/) — `OrderController` (exception-based, custom orchestration action returning 202 Accepted) +- `coreex-test-api` — the integration-test workflow for the endpoints this skill scaffolds diff --git a/.github/skills/coreex-subscriber/SKILL.md b/.github/skills/coreex-subscriber/SKILL.md index 19dc7103..dade84f1 100644 --- a/.github/skills/coreex-subscriber/SKILL.md +++ b/.github/skills/coreex-subscriber/SKILL.md @@ -1,6 +1,6 @@ --- name: coreex-subscriber -description: "Add or modify an event/command subscriber in a CoreEx Subscribe host. USE FOR: command subscriber (owns the contract, delegates to app service), event-data-sync subscriber (delegates to IXxxSyncAdapter), event-business-process subscriber (choreography step, delegates to app service). Covers SubscribedBase, SubscribedBase, ValueValidator, ErrorHandler, subject naming, and Subscribe-test integration tests. DO NOT USE FOR: API controllers (use coreex-api), application services (use coreex-app-service), replication adapter implementations (use coreex-adapter), Subscribe host Program.cs setup (see coreex-host-setup.instructions.md)." +description: "Add or modify an event/command subscriber in a CoreEx Subscribe host. USE FOR: command subscriber (owns the contract, delegates to app service), event-data-sync subscriber (delegates to IXxxSyncAdapter), event-business-process subscriber (choreography step, delegates to app service). Covers SubscribedBase, SubscribedBase, ValueValidator, ErrorHandler, and subject naming. DO NOT USE FOR: API controllers (use coreex-api), application services (use coreex-app-service), replication adapter implementations (use coreex-adapter), Subscribe host Program.cs setup (see coreex-host-setup.instructions.md), Subscribe-test integration tests (use coreex-test-subscribe)." argument-hint: "Optional: subscriber scenario (command / event-sync / event-process), subject string, payload type, whether ErrorHandler is needed" tags: ["subscriber", "messaging", "service-bus", "event-handling", "choreography", "saga", "coreex"] --- @@ -41,7 +41,7 @@ There are three distinct subscriber scenarios — determine which applies before - Subject format: `{solution}.{domain}.{entity}.{action}[.v{n}]` — include `.v{n}` only when the message carries a payload - `EventData.CreateCommand(...)` for commands; `EventData.CreateEvent(...)` / `new EventData().WithTitle(...)` for events - `ErrorHandler` for graceful not-found and retry/dead-letter control — share the same static instance across related subscribers -- Tests use `WithApiTester` (Subscribe host); simulate receipt via `ServiceBusSubscribedSubscriber.ReceiveAsync(sbm)` +- Integration tests use `WithApiTester` (Subscribe host); simulate receipt via `ServiceBusSubscribedSubscriber.ReceiveAsync(sbm)` — see `coreex-test-subscribe` for the full test workflow For full workflow and code examples see [`references/workflow.md`](references/workflow.md). @@ -51,7 +51,6 @@ For full workflow and code examples see [`references/workflow.md`](references/wo - `samples/src/Contoso.Products.Subscribe/Subscribers/ReservationCancelSubscriber.cs` — command subscriber sharing an `ErrorHandler` - `samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductModifySubscriber.cs` — typed event-sync subscriber with `ValueValidator` - `samples/src/Contoso.Shopping.Subscribe/Subscribers/ProductDeleteSubscriber.cs` — untyped event-sync subscriber (key-only delete) -- `samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationConfirm.cs` — command subscriber test (outbox assertion + ErrorHandler) -- `samples/tests/Contoso.Shopping.Test.Subscribe/SubscriberTests.ProductModify.cs` — event-sync subscriber test (state assertion) +- `coreex-test-subscribe` — full Subscribe-test integration test workflow (test class shape, simulating message receipt, command/event-sync/event-business-process test patterns, unsubscribed-subject test) - `.github/instructions/coreex-event-subscribers.instructions.md` — full subscriber conventions reference - `.github/instructions/coreex-host-setup.instructions.md` — Subscribe host `Program.cs` shape diff --git a/.github/skills/coreex-subscriber/references/workflow.md b/.github/skills/coreex-subscriber/references/workflow.md index 76398cb1..6641a2af 100644 --- a/.github/skills/coreex-subscriber/references/workflow.md +++ b/.github/skills/coreex-subscriber/references/workflow.md @@ -227,154 +227,12 @@ builder.Services ## Step 5 — Integration Tests -Subscriber tests use `WithApiTester` (from the Subscribe host's `Program` class). The -`ServiceBusSubscribedSubscriber` simulates broker message receipt without a live Service Bus -connection. - -### Test class shape - -```csharp -// Tests/SubscriberTests.{Scenario}.cs (partial class — split by scenario) -namespace {Domain}.Test.Subscribe; - -public partial class SubscriberTests : WithApiTester<{Domain}.Subscribe.Program> -{ - [OneTimeSetUp] - public async Task OneTimeSetUpAsync() - { - // Seed + migrate: always specify the seed file explicitly. - // Read tests → "read-data.seed.yaml", mutate/subscribe tests → "mutate-data.seed.yaml", - // schema-only tests (health, relay) → "no-data.seed.yaml". - // SQL Server: MigrateSqlServerDataAsync; PostgreSQL: MigratePostgresDataAsync. - await Test.MigrateSqlServerDataAsync(["mutate-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); - await Test.ClearFusionCacheAsync().ConfigureAwait(false); - - // Register the outbox publisher expectation for the domain's database engine. - Test.UseExpectedSqlServerOutboxPublisher(); // SQL Server - // Test.UseExpectedPostgresOutboxPublisher(); // PostgreSQL - } -} -``` - -### Simulating message receipt - -```csharp -// Build the event and convert to a ServiceBusReceivedMessage -var ed = EventData.CreateCommand("products", "reservation", "confirm").WithKey(referenceId); -// For events with payload: -// var ed = new EventData().WithTitle("contoso.products.product.updated.v1").WithValue(product); - -var ce = Test.CreateCloudEventFrom(ed); -var sbm = ce.ToServiceBusReceivedMessage(); - -var sbs = test.Services.GetRequiredService(); -var r = await sbs.ReceiveAsync(sbm); -r.IsSuccess.Should().BeTrue(); -``` - -### Command subscriber test (outbox assertion + `ErrorHandler`) - -```csharp -[Test] -public void {Entity}{Action}_NotFound() => Test.Scoped(test => -{ - var ed = EventData.CreateCommand("{domain}", "{entity}", "{action}").WithKey("missing-id"); - var ce = Test.CreateCloudEventFrom(ed); - var sbm = ce.ToServiceBusReceivedMessage(); - - test.Run(async _ => - { - var sbs = test.Services.GetRequiredService(); - var r = await sbs.ReceiveAsync(sbm); - - r.IsFailure.Should().BeTrue(); - var e = r.Error.Should().BeOfType().Subject; - e.ErrorHandling.Should().Be(ErrorHandling.CompleteAsInformation); - e.InnerException.Should().BeOfType().Which.ErrorCode.Should().Be("{entity}-not-found"); - }).AssertSuccess(); -}); - -[Test] -public void {Entity}{Action}_Success() => Test.Scoped(async test => -{ - // Arrange — use a service or repository to confirm pre-conditions. - var referenceId = 1000.ToGuid().ToString(); - var svc = test.Services.GetRequiredService(); - var items = await svc.GetAsync(referenceId); - items.Should().HaveCount(3); - - // Act — simulate the command message. - test.ExpectSqlServerOutboxEvents(e => e.AssertCount(3)) - .Run(async _ => - { - var ed = EventData.CreateCommand("{domain}", "{entity}", "{action}").WithKey(referenceId); - var ce = Test.CreateCloudEventFrom(ed); - var sbm = ce.ToServiceBusReceivedMessage(); - - var sbs = test.Services.GetRequiredService(); - var r = await sbs.ReceiveAsync(sbm); - r.IsSuccess.Should().BeTrue(); - }).AssertSuccess(); - - // Assert — verify state change. - items = await svc.GetAsync(referenceId); - items.Should().AllSatisfy(i => i.StatusCode.Should().Be({Status}.Confirmed)); -}); -``` - -### Event-sync subscriber test (state assertion) - -```csharp -[Test] -public void {Entity}Modify_Success() => Test.Scoped(async test => -{ - // Arrange — get current state and apply a change. - var pa = test.Services.GetRequiredService(); - var result = await pa.GetAsync(knownId); - result.IsSuccess.Should().BeTrue(); - var entity = result.Value; - entity.Name += " modified"; - - var ed = new EventData().WithTitle("{solution}.{external}.{entity}.updated.v1").WithValue(entity); - var ce = Test.CreateCloudEventFrom(ed); - var sbm = ce.ToServiceBusReceivedMessage(); - - // Act. - test.Run(async _ => - { - var sbs = test.Services.GetRequiredService(); - var r = await sbs.ReceiveAsync(sbm); - r.IsSuccess.Should().BeTrue(); - }).AssertSuccess(); - - // Assert — verify the local store reflects the change. - result = await pa.GetAsync(knownId); - result.Value.Name.Should().EndWith(" modified"); -}); -``` - -**Unsubscribed subject test** — always include one to confirm unknown subjects are consumed silently: - -```csharp -[Test] -public void Unsubscribed_Error() => Test.Scoped(test => -{ - var ed = EventData.CreateEvent("test", "not-subscribed").WithKey("abc"); - var ce = Test.CreateCloudEventFrom(ed); - var sbm = ce.ToServiceBusReceivedMessage(); - - test.Run(async _ => - { - var sbs = test.Services.GetRequiredService(); - var r = await sbs.ReceiveAsync(sbm); - - r.IsFailure.Should().BeTrue(); - var e = r.Error.Should().BeOfType().Subject; - e.ErrorHandling.Should().Be(ErrorHandling.CompleteAsSilent); - e.InnerException.Message.Should().Be("No subscriber matched the event."); - }).AssertSuccess(); -}); -``` +Always provide a test for a new or modified subscriber. Full workflow, test-class shape, message-receipt +simulation, and per-scenario test patterns (command / event-data-sync / event-business-process, plus the +required "unsubscribed subject" test) now live in the dedicated **`coreex-test-subscribe`** skill — see +[`../coreex-test-subscribe/references/workflow.md`](../coreex-test-subscribe/references/workflow.md). +That skill also links back to `coreex-test-api` for the shared DB/cache/outbox `OneTimeSetUp` mechanics +(seed authoring, provider-specific outbox helpers) common to both API and Subscribe host tests. --- diff --git a/.github/skills/coreex-test-api/SKILL.md b/.github/skills/coreex-test-api/SKILL.md new file mode 100644 index 00000000..c99adea4 --- /dev/null +++ b/.github/skills/coreex-test-api/SKILL.md @@ -0,0 +1,53 @@ +--- +name: coreex-test-api +description: "Write or update an integration test in a CoreEx *.Test.Api project. USE FOR: new XxxReadTests/XxxMutateTests partial classes, per-operation test files (Get/Query/Create/Update/Patch/Delete), OneTimeSetUp seeding + cache + outbox wiring, seed data (read-data.seed.yaml/mutate-data.seed.yaml), .res.json/.req.json resources, ETag/concurrency and soft-delete scenarios, outbox event assertions, inter-domain HTTP mocking. DO NOT USE FOR: Subscribe host tests (use coreex-test-subscribe), Outbox Relay host tests (use coreex-test-relay), pure unit tests with no infrastructure (validators/aggregates/adapters already cover their own Test.Unit guidance), the controller/endpoint implementation itself (use coreex-api)." +argument-hint: "Entity/endpoint name, operations to cover (Get/Query/Create/Update/Patch/Delete), database provider (PostgreSQL/SQL Server)" +tags: ["testing", "integration-tests", "api", "unittestex", "coreex"] +--- + +# CoreEx: API Integration Test + +Guides you through writing or extending `*.Test.Api` integration tests — the real host, real DB, real +cache, real outbox, over `WithApiTester<{Solution}.Api.Program>`. This is the canonical integration-test +setup shared with Subscribe host tests (`coreex-test-subscribe` links back here for the common parts). + +## When to Use + +- Adding a new entity's read/mutate API tests from scratch +- Adding a test for a single new operation (Get/Query/Create/Update/Patch/Delete) on an existing entity +- Adding an ETag/concurrency, soft-delete, idempotent-delete, or outbox-event scenario +- Wiring `OneTimeSetUp` (DB migrate/seed, cache clear, outbox publisher, inter-domain HTTP mocks) for a new test class +- Called standalone ("write the test for X") or as the hand-off step from `coreex-api` once an endpoint is implemented + +## When Not to Use + +- Subscribe host integration tests — use `coreex-test-subscribe` (shares this skill's DB/cache/outbox setup, differs in trigger mechanism) +- Outbox Relay host tests — use `coreex-test-relay` (mostly templated, rarely extended) +- Validator/aggregate/adapter unit tests (`*.Test.Unit`) — already covered by `coreex-validator`/`coreex-aggregate`/`coreex-adapter` +- Implementing the controller/endpoint itself — use `coreex-api` + +## Quick Reference + +- **One class pair per entity**: `XxxReadTests` (seeds `read-data.seed.yaml`) / `XxxMutateTests` (seeds `mutate-data.seed.yaml`) — each a `partial class`, one operation per sub-file: `Xxx{Read|Mutate}Tests.{Operation}.cs` +- **`[OneTimeSetUp]`** lives in the base partial file: migrate + seed (named-file overload) → `Test.ClearFusionCacheAsync()` → provider-specific `Test.UseExpected{Postgres|SqlServer}OutboxPublisher()` → HTTP mocks if the domain has adapters +- **Seed → Tests → Resources**, in that order — seed known `^N` rows first, write tests referencing them, capture `.res.json`/`.req.json` from the actual run +- **One seed row per destructive test** (not per operation) — NUnit randomises order, so two mutate tests sharing a row collide non-deterministically +- **`Test.Http()` / `Test.Http()`** fluent chain: expectations → `.Run(...)` → assert +- **Expectation helpers auto-exclude from JSON compare**: `ExpectIdentifier()`, `ExpectETag()`, `ExpectChangeLogCreated()`/`Updated()` — omit those fields from `.res.json` +- **412 vs 409 vs 428**: stale ETag → `AssertPreconditionFailed()` (412); duplicate/business conflict → `AssertConflict()` (409); no ETag supplied → `428` +- **Delete is idempotent**: always `AssertNoContent()` (204), never `AssertNotFound()` on DELETE — the 404 belongs to the follow-up GET; only the first delete emits an outbox event +- **Outbox assertions are provider-specific** — `ExpectPostgresOutboxEvents`/`ExpectSqlServerOutboxEvents`; `.AssertWithValue(destination, subject)` for value-carrying events (Create/Update), `.AssertMetadata(destination, subject, key)` for no-value events (Delete) +- **Reference-data JSON name**: non-`Code` suffix (`gender`, not `genderCode`) in `.res.json`/`.req.json`/inline bodies +- **HTTP client mocking**: `MockHttpClientFactory` + `MockHttpClientRequest` fields configured in `OneTimeSetUp`, per-test `.Respond.With(...)`/`.Respond.WithJsonResource(...)`, always `.Verify()` +- Name tests `{Entity}_{Action}_{Outcome}` (e.g. `Product_Create_Success`) + +For full workflow, decision trees, and code patterns see [`references/workflow.md`](references/workflow.md). + +## Key References + +- [`/.github/instructions/coreex-tests.instructions.md`](/.github/instructions/coreex-tests.instructions.md) — full, authoritative test conventions (auto-injected on any `*.Test*/**/*.cs` edit); this skill is a practical workflow layered on top of it, not a replacement +- [`/samples/docs/testing.md`](/samples/docs/testing.md) — test architecture, data seeding, schema isolation, E2E runner +- `samples/tests/Contoso.Products.Test.Api/` — PostgreSQL domain example (outbox + HTTP mock adapter) +- `samples/tests/Contoso.Shopping.Test.Api/` — SQL Server domain example (outbox + ETag/concurrency) +- `coreex-test-subscribe` — Subscribe host tests, shares this skill's setup foundations +- `coreex-api` — the controller/endpoint implementation this skill's tests exercise diff --git a/.github/skills/coreex-test-api/references/workflow.md b/.github/skills/coreex-test-api/references/workflow.md new file mode 100644 index 00000000..a63f7cee --- /dev/null +++ b/.github/skills/coreex-test-api/references/workflow.md @@ -0,0 +1,240 @@ +# API Integration Test — Full Workflow + +## Phase 0 — Confirm Scope + +Identify: entity name, which operations need coverage (Get/Query/Create/Update/Patch/Delete), and the +domain's database provider (check `*.Database/Program.cs` or `appsettings.json` — PostgreSQL vs SQL +Server). The provider determines which outbox helper family to use throughout (never mix them). + +## Phase 1 — Seed Data First + +Test seed data lives under `Data/` in the `*.Test.Common` project, located via the `TestData` marker +class (do not rename/move it). Use **one read dataset and one mutate dataset per domain**: +`read-data.seed.yaml` / `mutate-data.seed.yaml`, shared across all entities in the domain. + +- Format: `schema:` → `- :` → rows as **inline objects** keyed by column name (no `$`/`$^` + prefix — unlike production ref-data seed files). +- **Casing must match the database exactly** for the provider: + - PostgreSQL (default) — lowercase `snake_case`: schema `bar`, table `employee`, columns + `employee_id`, `first_name`. + - SQL Server — `PascalCase`: schema `Bar`, table `Employee`, columns `EmployeeId`, `FirstName`. + A wrong-cased schema/table fails with *"Table '…' does not exist"*. +- **`^N` is a deterministic GUID** (`^1` == `1.ToGuid()`) — use it for the identifier and any GUID + foreign-key reference to another seeded row. +- Reference data is linked **by code** (`gender_code: M`), not an id/FK. +- Set scenario flags explicitly where needed (`is_deleted: true`, `is_inactive: true`). + +**One seed row per destructive test — not per operation.** NUnit randomises execution order, so two +*tests* that mutate the same row collide non-deterministically even if each *operation* nominally has +its own row. Provision rows up front: + +| `^N` | Test | Notes | +|---|---|---| +| `^1` | `Update_Success` | `Update_NotFound` uses a non-existent id; `Update_ConcurrencyError` only reads `^1` (rolls back) so may share it | +| `^2` | `Delete_*` | the row is removed by the test | +| `^3` | `Patch_Success` | | + +Non-mutating tests (Get, Query, 304, validation) can freely share read rows. + +**GUID literals in resource files** (`.res.json`/`.event.json`) — when a deterministic id/FK must appear +as a literal string, compute `N.ToGuid()` correctly: the number goes in the **first segment**, in +**lowercase hex**, zero-padded to 8 digits — `1.ToGuid()` = `00000001-0000-0000-0000-000000000000`, not +`...-000000000001`. Prefer excluding volatile `id` fields entirely (the `Expect*` helpers auto-exclude) +so no literal is needed. + +## Phase 2 — Test Classes (per entity, read vs mutate) + +``` +EmployeeReadTests.cs // [OneTimeSetUp] → read-data.seed.yaml + ClearFusionCacheAsync +EmployeeReadTests.Get.cs +EmployeeReadTests.Query.cs +EmployeeMutateTests.cs // [OneTimeSetUp] → mutate-data.seed.yaml (+ outbox publisher, HTTP mocks) +EmployeeMutateTests.Create.cs +EmployeeMutateTests.Update.cs +EmployeeMutateTests.Patch.cs +EmployeeMutateTests.Delete.cs +``` + +Each is a `partial class : WithApiTester<{Solution}.Api.Program>` (reference `Program` fully-qualified — +it's `public`, no extra `using` needed). The `*.Test.Api` project's `GlobalUsing.cs` already provides +`DbMigration` (= `{Solution}.Database.Program`) and `TestData` (= `{Solution}.Test.Common.TestData`) +aliases — use them, don't re-derive. + +```csharp +// EmployeeMutateTests.cs +public partial class EmployeeMutateTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.MigrateSqlServerDataAsync(["mutate-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + Test.UseExpectedSqlServerOutboxPublisher(); // or UseExpectedPostgresOutboxPublisher() — provider-specific + } +} + +// EmployeeMutateTests.Create.cs +public partial class EmployeeMutateTests +{ + [Test] + public void Create_Success() => /* Test.Http()… .AssertCreated()… */; +} +``` + +Use the **named-file** seed overload so read/mutate classes only load their own dataset — the +no-argument overload loads every `Data/*.seed.yaml` and mixes datasets. + +If the domain calls another domain over HTTP, add HTTP mocking to `OneTimeSetUp` too (see Phase 5). + +## Phase 3 — Author Tests Per Operation + +Co-design seed → tests → resources **in that order** (seed rows exist before you write assertions +against them; resources get captured from the actual run). + +| Operation | Must cover | +|---|---| +| **Get** | found, not-found, ETag/If-None-Match → 304 | +| **Query** | filter, order, paging, field selection | +| **Create** | success + `Location` header + outbox event; bad-data validation; duplicate/conflict; idempotency-key | +| **Update** | success + ETag/concurrency (412); not-found (still needs a valid ETag + full body — see below) | +| **Patch** | merge success (`.WithMergePatchJsonContentType()`); not-found | +| **Delete** | idempotent four-step flow: get → delete → get → delete | + +### Fluent pattern + +```csharp +// GET +Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}") + .AssertOK() + .AssertJsonFromResource("ReadTests.Product_Get_Found.res.json", "etag", "changelog"); + +// POST — value-carrying create, provider-specific outbox assertion +var created = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogCreated() + .ExpectJsonFromResource("ProductMutateTests.Create_Success.res.json") + .ExpectPostgresOutboxEvents(e => e.AssertWithValue("contoso", "contoso.products.product.created.v1")) + .Run(HttpMethod.Post, "/api/products", product) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)) + .Value!; + +// Validation error +Test.Http() + .Run(HttpMethod.Post, "/api/products", invalidProduct) + .AssertBadRequest() + .AssertErrors("Text is required.", "Price must be greater than or equal to zero."); +``` + +`ExpectIdentifier()` / `ExpectETag()` / `ExpectChangeLogCreated()`/`Updated()` **both assert presence and +auto-exclude from the JSON compare** — omit those fields from `.res.json`, don't list them as manual +excludes. + +### ETag / concurrency (Update, Patch) + +```csharp +var val = Test.Http().Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}").AssertOK().Value!; +val.Text = "Updated text"; +Test.Http() + .Run(HttpMethod.Put, $"/api/products/{val.Id}", val, requestModifier: r => r.WithIfMatch(val.ETag)) + .AssertOK(); +``` + +Stale ETag → `AssertPreconditionFailed()` (**412**, `ConcurrencyException`) — never `AssertConflict()` +(409 is reserved for duplicate-key/business conflicts). `If-Match` header takes precedence over any body +`ETag`, so no need to clear `val.ETag` when testing the failure path. + +**Update of a non-existent id → 404, but concurrency is checked first.** The test must still send a +valid `If-Match` and a complete body — otherwise you get `428 Precondition Required` instead of the +intended `404`. + +### Conditional GET (304) + +```csharp +var r = Test.Http().Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}").AssertOK().Response; +Test.Http() + .Run(HttpMethod.Get, $"/api/products/{1.ToGuid()}", requestModifier: rm => rm.WithIfNoneMatch(r.Headers.ETag!.Tag)) + .AssertNotModified(); +``` + +Use `WithIfNoneMatch(...)` — never set the header by hand (`If-None-Match` requires a quoted entity-tag; +raw `Headers.Add` throws `FormatException` on an unquoted value). + +### Soft-delete → 404 on read + +Seed a row with the delete flag set (`is_deleted: true` / `IsDeleted: true`), then assert the direct GET +404s — this proves the filter is actually applied on read, not just present in the table. + +### Delete — idempotent four-step flow + +```csharp +Test.Http().Run(HttpMethod.Get, $"/api/products/{2.ToGuid()}").AssertOK(); // 1. exists + +Test.Http() + .ExpectPostgresOutboxEvents(e => e.AssertMetadata("contoso", "contoso.products.product.deleted", 2.ToGuid().ToString())) + .Run(HttpMethod.Delete, $"/api/products/{2.ToGuid()}") + .AssertNoContent(); // 2. 204 + event + +Test.Http().Run(HttpMethod.Get, $"/api/products/{2.ToGuid()}").AssertNotFound(); // 3. now 404 + +Test.Http() + .ExpectNoPostgresOutboxEvents() + .Run(HttpMethod.Delete, $"/api/products/{2.ToGuid()}") + .AssertNoContent(); // 4. still 204, no event +``` + +DELETE **always** returns 204, **never** 404 — the 404 belongs to the GET. Only the first delete (where +the row existed) emits an event. + +## Phase 4 — Outbox Event Assertions + +Pick the assertor by whether the event carries a value: + +- **`.AssertWithValue(destination, subject)`** — value-carrying events (Create/Update); reconstructs the + expected `EventData` from the API's returned value. +- **`.AssertMetadata(destination, subject, key)`** — no-value events (Delete, any `204`); asserts + destination + subject + the `key` (the deleted id). + +Subject = `{solutionname}.{domainname}.{entity}.{action}` (all lower-case) + optional `.v{major}` suffix +**present only when the event carries a value** (default `.v1`, or the contract's `[Schema("vX.Y")]` +major). If unsure of the exact subject, run the test once — the assertion failure reports the actual +subject; copy it verbatim. + +## Phase 5 — Inter-Domain HTTP Mocking + +Never call a real downstream API in a test — always mock via `MockHttpClientFactory`: + +```csharp +// OneTimeSetUp +var mcf = MockHttpClientFactory.Create(); +_mockHttpReserveRequest = mcf.CreateClient("ProductsApi").Request(HttpMethod.Post, "api/inventory/reserve"); +Test.ReplaceHttpClientFactory(mcf); + +// In test +_mockHttpReserveRequest.WithJsonResourceBody("Basket_Checkout_Success.products.req.json").Respond.With(HttpStatusCode.OK); +_mockHttpReserveRequest.Verify(); +``` + +Configure `ReplaceHttpClientFactory()` once in `OneTimeSetUp`, never inside individual tests. Always +call `.Verify()` after the action. + +## Phase 6 — Resources + +`.res.json`/`.req.json` live under `Resources/{TestClass}/…`. Pre-author from the seed values you +control; expect the first run to need a small fix-up from actual output — that's normal. Reference-data +properties serialize by their **non-`Code`** JSON name (`gender`, not `genderCode`) — real, deterministic +values, include them; don't exclude them. The only volatile fields are `id`/`etag`/`changelog`. + +## Completion Checklist + +- [ ] Read/mutate split into separate classes with separate seed files +- [ ] Named-file seed overload used (not the no-arg "load everything" overload) +- [ ] One seed row per destructive test, not per operation +- [ ] Provider-correct outbox helpers used throughout (no Postgres/SQL Server mixing) +- [ ] Delete tested as the idempotent 4-step flow; never `AssertNotFound()` on DELETE +- [ ] ETag concurrency asserted as 412, not 409; 428 case understood for missing-ETag scenarios +- [ ] `.res.json` omits fields covered by `Expect*` auto-exclude helpers +- [ ] `.Verify()` called after every `MockHttpClientRequest` use +- [ ] Tests named `{Entity}_{Action}_{Outcome}` diff --git a/.github/skills/coreex-test-relay/SKILL.md b/.github/skills/coreex-test-relay/SKILL.md new file mode 100644 index 00000000..407f6f6e --- /dev/null +++ b/.github/skills/coreex-test-relay/SKILL.md @@ -0,0 +1,89 @@ +--- +name: coreex-test-relay +description: "Understand, verify, or (rarely) extend an Outbox Relay host integration test in a CoreEx *.Test.Relay project. USE FOR: understanding the templated RelayTests.cs shape, verifying the relay forwards outbox events to the broker, hosted-service pause/resume endpoint checks, diagnosing Service Bus emulator entity-not-found failures. DO NOT USE FOR: API host tests (use coreex-test-api), Subscribe host tests (use coreex-test-subscribe), the relay host's Program.cs/hosted-service setup (see coreex-host-setup.instructions.md)." +argument-hint: "Optional: what you're trying to verify (relay forwarding, hosted-service pause/resume) or a specific failure to diagnose" +tags: ["testing", "integration-tests", "relay", "outbox", "service-bus", "unittestex", "coreex"] +--- + +# CoreEx: Outbox Relay Host Integration Test + +Guides you through the `*.Test.Relay` project — the most templated and least-extended of the three +integration test projects. Unlike API/Subscribe tests, which grow one file per entity/subscriber as a +domain evolves, relay tests are largely **generic plumbing checks** generated by `CoreEx.Template` and +rarely need new scenarios per domain. + +## When to Use + +- Understanding what `RelayTests.cs` already verifies before assuming it needs extending +- Verifying the relay forwards outbox-written events to the broker end-to-end +- Adding a hosted-service pause/resume/status check +- Diagnosing a Service Bus emulator "entity not found" test failure +- The rare case where a domain has relay behavior beyond the generic forward-and-verify flow (e.g. a + custom hosted-service management endpoint) + +## When Not to Use + +- API host integration tests — use `coreex-test-api` +- Subscribe host integration tests — use `coreex-test-subscribe` +- Relay host `Program.cs` / hosted-service registration — see `.github/instructions/coreex-host-setup.instructions.md` + +## Quick Reference + +- **Base class**: `WithApiTester<{Domain}.Relay.Program>` — relay hosts have **no** FusionCache; do not call `ClearFusionCacheAsync()` here +- **No DB/cache seeding needed for the core forwarding test** — it writes directly to the outbox via `Test.ScopedType` and a provider-specific outbox publisher (`PostgresOutboxPublisher`/`SqlServerOutboxPublisher`), then waits for the background relay service to forward it +- **Assert delivery** via `Test.GetAndClearAzureServiceBusAsync(...)` against the expected topic/subscription +- **Hosted-service management endpoints** are also testable over plain HTTP — `/hosted-services/{name}/pause`, `/resume`, etc. +- **A domain rarely needs more than the templated `RelayTests.cs` + `OtherTests.Health.cs` + `OtherTests.HostedServices.cs`** — check what already exists before writing something new + +```csharp +public class RelayTests : WithApiTester +{ + [Test] + public async Task Outbox_Relay() + { + Test.ScopedType(test => + { + test.Run(async _ => + { + var pub = ActivatorUtilities.GetServiceOrCreateInstance(test.Services); + pub.Add("contoso", [ce1, ce2]); + await pub.PublishAsync(); + + for (int i = 0; i < 5; i++) + await Task.Delay(TimeSpan.FromSeconds(1)); + + var list = await Test.GetAndClearAzureServiceBusAsync( + ServiceBusSessionReceiverOptions.CreateForTopicSubscription("contoso", "products")); + + list.Should().HaveCount(2); + }).AssertSuccess(); + }); + } +} +``` + +```csharp +Test.Http() + .Run(HttpMethod.Post, "/hosted-services/postgres-outbox-relay-03/pause") + .Response.StatusCode.Should().Be(HttpStatusCode.Accepted); +``` + +## Troubleshooting — Service Bus Emulator "Entity Not Found" + +If a Relay (or any Service Bus) test fails with: + +``` +The messaging entity 'sb://sbemulatorns.servicebus.onebox.windows-int.net//subscriptions/' could not be found. +``` + +the test host reached the emulator but the topic/subscription doesn't exist in it. **This is an +environment problem, not a test-code defect** — check that the Service Bus emulator container is +running with the correct `/servicebus/Config.json` (the emulator provisions topics/subscriptions from +that config at startup). Do not "fix" it by editing the test, subject names, or emulator entity names. + +## Key References + +- [`/.github/instructions/coreex-tests.instructions.md`](/.github/instructions/coreex-tests.instructions.md) — full, authoritative test conventions, "Outbox Relay Host Tests" section +- `samples/tests/Contoso.Products.Test.Relay/RelayTests.cs` — canonical outbox-forwarding test +- `samples/tests/Contoso.Products.Test.Relay/OtherTests.Health.cs`, `OtherTests.HostedServices.cs` — health and hosted-service management checks +- `coreex-test-api` / `coreex-test-subscribe` — the other two integration test skills, both more likely to need per-domain extension than this one diff --git a/.github/skills/coreex-test-subscribe/SKILL.md b/.github/skills/coreex-test-subscribe/SKILL.md new file mode 100644 index 00000000..b6d4b7c6 --- /dev/null +++ b/.github/skills/coreex-test-subscribe/SKILL.md @@ -0,0 +1,50 @@ +--- +name: coreex-test-subscribe +description: "Write or update an integration test in a CoreEx *.Test.Subscribe project. USE FOR: SubscriberTests partial classes, simulating broker message receipt via ServiceBusSubscribedSubscriber, command/event-data-sync/event-business-process test scenarios, ErrorHandler outcome assertions, unsubscribed-subject tests. Shares DB/cache/outbox OneTimeSetUp foundations with coreex-test-api — see that skill for the shared setup mechanics. DO NOT USE FOR: API host tests (use coreex-test-api), Outbox Relay host tests (use coreex-test-relay), the subscriber implementation itself (use coreex-subscriber)." +argument-hint: "Subscriber name/scenario (command / event-sync / event-business-process), subject string, payload type, ErrorHandler outcomes to cover" +tags: ["testing", "integration-tests", "subscriber", "messaging", "unittestex", "coreex"] +--- + +# CoreEx: Subscribe Host Integration Test + +Guides you through writing or extending `*.Test.Subscribe` integration tests — simulating broker +message receipt against the real Subscribe host, real DB, real cache, real outbox, over +`WithApiTester<{Domain}.Subscribe.Program>`. Subscribe hosts share the same DB/cache/outbox +`OneTimeSetUp` foundation as API hosts (see `coreex-test-api`) — the only real difference is *how* the +test triggers behavior: a simulated message receipt instead of an HTTP call. + +## When to Use + +- Adding a test for a new command subscriber, event-data-sync subscriber, or event-business-process subscriber +- Asserting an `ErrorHandler` outcome (e.g. `CompleteAsInformation` on a semantically-expected not-found) +- Asserting state/adapter changes after an event-sync subscriber processes a payload +- Asserting outbox events published as a *result* of processing a command/business-process message +- Called standalone or as the hand-off step from `coreex-subscriber` once a subscriber is implemented + +## When Not to Use + +- API host integration tests — use `coreex-test-api` (owns the shared DB/cache/outbox setup this skill builds on) +- Outbox Relay host tests — use `coreex-test-relay` +- Implementing the subscriber class itself — use `coreex-subscriber` + +## Quick Reference + +- **Base class**: `WithApiTester<{Domain}.Subscribe.Program>` — same DB/cache/outbox `[OneTimeSetUp]` shape as API tests (migrate + seed via named-file overload → `ClearFusionCacheAsync()` → provider-specific `UseExpected{Postgres|SqlServer}OutboxPublisher()`); Subscribe hosts **do** have FusionCache (reference data, idempotency) +- **Simulate receipt**: build an `EventData` → `Test.CreateCloudEventFrom(ed)` → `.ToServiceBusReceivedMessage()` → resolve `ServiceBusSubscribedSubscriber` from DI → `.ReceiveAsync(sbm)` +- **One partial file per subscriber scenario** — `SubscriberTests.{Scenario}.cs` +- **Match the test shape to the subscriber scenario**: command → assert outcome + outbox events published as a result; event-data-sync → assert local state/adapter reflects the payload; event-business-process → assert the downstream service ran and published its own events +- **`ErrorHandler` outcomes are assertable** — a handled exception surfaces as `EventSubscriberHandledException` with `.ErrorHandling` and `.InnerException` to check +- **Always include an "unsubscribed subject" test** — confirms unknown subjects are consumed silently (`ErrorHandling.CompleteAsSilent`), not dead-lettered +- **Seed file naming carries over unchanged** — `read-data.seed.yaml` / `mutate-data.seed.yaml` / a schema-only `no-data.seed.yaml` for health/plumbing-only tests + +For full workflow and code patterns see [`references/workflow.md`](references/workflow.md). For the +shared DB/cache/outbox setup mechanics (seed data authoring, provider-specific outbox helpers), see +[`../coreex-test-api/references/workflow.md`](../coreex-test-api/references/workflow.md). + +## Key References + +- [`/.github/instructions/coreex-tests.instructions.md`](/.github/instructions/coreex-tests.instructions.md) — full, authoritative test conventions, "Subscribe Host Tests" section +- `coreex-test-api` — shared integration-test setup foundations (DB migrate/seed, cache, outbox) +- `coreex-subscriber` — the subscriber implementation this skill's tests exercise; its three scenarios (command / event-data-sync / event-business-process) map directly to this skill's test shapes +- `samples/tests/Contoso.Products.Test.Subscribe/SubscriberTests.ReservationConfirm.cs` — command subscriber test (outbox assertion + `ErrorHandler`) +- `samples/tests/Contoso.Shopping.Test.Subscribe/SubscriberTests.ProductModify.cs` — event-sync subscriber test (state assertion) diff --git a/.github/skills/coreex-test-subscribe/references/workflow.md b/.github/skills/coreex-test-subscribe/references/workflow.md new file mode 100644 index 00000000..9a9cf478 --- /dev/null +++ b/.github/skills/coreex-test-subscribe/references/workflow.md @@ -0,0 +1,193 @@ +# Subscribe Host Integration Test — Full Workflow + +## Phase 0 — Match the Test Shape to the Subscriber Scenario + +Before writing anything, confirm which of the three subscriber scenarios (see `coreex-subscriber`) the +subscriber under test implements — it determines what you assert: + +| Scenario | Delegates to | What the test asserts | +|---|---|---| +| **Command** | Application service | Outcome (success/failure) + any outbox events published as a *result* of the command being processed | +| **Event — Data Sync** | Replication adapter (`IXxxSyncAdapter`) | Local state reflects the incoming payload (query the adapter/repository after receipt) | +| **Event — Business Process** | Application service (choreography step) | The downstream service ran and published its own resulting events | + +## Phase 1 — OneTimeSetUp (shared foundation) + +Subscribe tests use the **same DB/cache/outbox setup** as API tests — see +[`../coreex-test-api/references/workflow.md`](../coreex-test-api/references/workflow.md) Phase 1 for +seed-file authoring and Phase 4 for outbox assertion mechanics. Subscribe hosts **do** have FusionCache +(they're full application-layer consumers needing it for reference data and idempotency) — don't skip +`ClearFusionCacheAsync()` thinking it's API-only. + +```csharp +// SubscriberTests.cs (base partial file) +namespace {Domain}.Test.Subscribe; + +public partial class SubscriberTests : WithApiTester<{Domain}.Subscribe.Program> +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + // Always specify the seed file explicitly — read-data.seed.yaml, mutate-data.seed.yaml, + // or a schema-only no-data.seed.yaml for plumbing/health-only tests. + await Test.MigrateSqlServerDataAsync(["mutate-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedSqlServerOutboxPublisher(); // or UseExpectedPostgresOutboxPublisher() — provider-specific + } +} +``` + +One partial file per subscriber scenario under test: `SubscriberTests.{Scenario}.cs`. + +## Phase 2 — Simulating Message Receipt + +Build the `EventData` the subscriber expects, convert it to a `ServiceBusReceivedMessage`, resolve +`ServiceBusSubscribedSubscriber` from DI, and call `.ReceiveAsync(sbm)` — no live Service Bus connection +needed. + +```csharp +// Key-only command: +var ed = EventData.CreateCommand("{domain}", "{entity}", "{action}").WithKey(referenceId); + +// Payload-carrying event: +// var ed = new EventData().WithTitle("{solution}.{domain}.{entity}.updated.v1").WithValue(entity); + +var ce = Test.CreateCloudEventFrom(ed); +var sbm = ce.ToServiceBusReceivedMessage(); + +var sbs = test.Services.GetRequiredService(); +var r = await sbs.ReceiveAsync(sbm); +r.IsSuccess.Should().BeTrue(); +``` + +## Phase 3 — Test Patterns Per Scenario + +### Command — success + outbox assertion + +```csharp +[Test] +public void {Entity}{Action}_Success() => Test.Scoped(async test => +{ + // Arrange — confirm pre-conditions via a service/repository. + var referenceId = 1000.ToGuid().ToString(); + var svc = test.Services.GetRequiredService(); + var items = await svc.GetAsync(referenceId); + items.Should().HaveCount(3); + + // Act — simulate the command message; assert any resulting outbox events. + test.ExpectSqlServerOutboxEvents(e => e.AssertCount(3)) + .Run(async _ => + { + var ed = EventData.CreateCommand("{domain}", "{entity}", "{action}").WithKey(referenceId); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + r.IsSuccess.Should().BeTrue(); + }).AssertSuccess(); + + // Assert — verify the resulting state change. + items = await svc.GetAsync(referenceId); + items.Should().AllSatisfy(i => i.StatusCode.Should().Be({Status}.Confirmed)); +}); +``` + +### Command — `ErrorHandler` outcome (semantically-expected not-found) + +A handled exception surfaces as `EventSubscriberHandledException`, wrapping the original exception and +carrying the resolved `ErrorHandling` outcome: + +```csharp +[Test] +public void {Entity}{Action}_NotFound() => Test.Scoped(test => +{ + var ed = EventData.CreateCommand("{domain}", "{entity}", "{action}").WithKey("missing-id"); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + test.Run(async _ => + { + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + + r.IsFailure.Should().BeTrue(); + var e = r.Error.Should().BeOfType().Subject; + e.ErrorHandling.Should().Be(ErrorHandling.CompleteAsInformation); + e.InnerException.Should().BeOfType().Which.ErrorCode.Should().Be("{entity}-not-found"); + }).AssertSuccess(); +}); +``` + +### Event — Data Sync (state assertion via the adapter) + +```csharp +[Test] +public void {Entity}Modify_Success() => Test.Scoped(async test => +{ + // Arrange — get current state and apply a change. + var adapter = test.Services.GetRequiredService(); + var result = await adapter.GetAsync(knownId); + result.IsSuccess.Should().BeTrue(); + var entity = result.Value; + entity.Name += " modified"; + + var ed = new EventData().WithTitle("{solution}.{domain}.{entity}.updated.v1").WithValue(entity); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + // Act. + test.Run(async _ => + { + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + r.IsSuccess.Should().BeTrue(); + }).AssertSuccess(); + + // Assert — verify the local store reflects the change. + result = await adapter.GetAsync(knownId); + result.Value.Name.Should().EndWith(" modified"); +}); +``` + +### Event — Business Process (choreography step) + +Same shape as the command test — assert the application service ran (state change) and, if it +publishes its own resulting events, assert those via the appropriate `ExpectXxxOutboxEvents`. + +## Phase 4 — Always Include: Unsubscribed Subject Test + +Confirms an unknown subject is consumed **silently**, not dead-lettered — this is broker-level plumbing +behavior worth asserting once per Subscribe host: + +```csharp +[Test] +public void Unsubscribed_Error() => Test.Scoped(test => +{ + var ed = EventData.CreateEvent("test", "not-subscribed").WithKey("abc"); + var ce = Test.CreateCloudEventFrom(ed); + var sbm = ce.ToServiceBusReceivedMessage(); + + test.Run(async _ => + { + var sbs = test.Services.GetRequiredService(); + var r = await sbs.ReceiveAsync(sbm); + + r.IsFailure.Should().BeTrue(); + var e = r.Error.Should().BeOfType().Subject; + e.ErrorHandling.Should().Be(ErrorHandling.CompleteAsSilent); + e.InnerException.Message.Should().Be("No subscriber matched the event."); + }).AssertSuccess(); +}); +``` + +## Completion Checklist + +- [ ] Test shape matches the subscriber scenario (command / event-data-sync / event-business-process) +- [ ] `OneTimeSetUp` clears FusionCache — Subscribe hosts have it, don't skip it +- [ ] Provider-correct outbox helpers used (no Postgres/SQL Server mixing) +- [ ] `ErrorHandler` outcomes asserted where the subscriber defines one (handled exception → `.ErrorHandling` + `.InnerException`) +- [ ] Event-data-sync tests assert via the adapter/local store, not the raw external contract +- [ ] An "unsubscribed subject" test exists for the host +- [ ] Seed files specified explicitly (no auto-discovery reliance) diff --git a/.github/skills/solution-scaffolder/SKILL.md b/.github/skills/solution-scaffolder/SKILL.md index 0452e158..c8923f5a 100644 --- a/.github/skills/solution-scaffolder/SKILL.md +++ b/.github/skills/solution-scaffolder/SKILL.md @@ -160,6 +160,7 @@ dotnet new coreex-subscribe -n Company.Product.Domain.Subscribe ... - Always run the unit test project when present. - Run API, relay, or subscriber test projects only when their required local dependencies are available; otherwise skip them and report the reason explicitly. - Treat missing local infrastructure such as SQL Server, Postgres, Redis, or Service Bus as a validation skip, not as a scaffolding failure, unless the user explicitly asked for full local environment setup. +- Once the scaffolded test projects build and run their initial templated tests, hand off to `coreex-test-api`, `coreex-test-subscribe`, or `coreex-test-relay` for authoring any further domain-specific integration tests — this skill only validates the generated shape, not test content. - When the user explicitly wants a first local runnable state, create any missing local dependency assets before broader validation, using bundled templates where available. - When `data-provider != None` and the user wants runnable local state, run the `*.Database` tool from its own project directory so `dbex.yaml` resolves correctly; prefer `dotnet run -- All` for first-run local setup. - When `refdata-enabled` is `true` and the user wants runnable local state, run the `*.CodeGen` tool from its own project directory so its config file resolves correctly. diff --git a/src/CoreEx.Template/CoreEx.Template.csproj b/src/CoreEx.Template/CoreEx.Template.csproj index dd2d712b..0f9e9560 100644 --- a/src/CoreEx.Template/CoreEx.Template.csproj +++ b/src/CoreEx.Template/CoreEx.Template.csproj @@ -146,6 +146,14 @@ <_AiFile Include="$(_RepoRoot).github/prompts/coreex-aggregate.prompt.md" DestSubPath=".github/prompts/coreex-aggregate.prompt.md" /> <_AiFile Include="$(_RepoRoot).github/skills/coreex-aggregate/SKILL.md" DestSubPath=".github/skills/coreex-aggregate/SKILL.md" /> <_AiFile Include="$(_RepoRoot).github/skills/coreex-aggregate/references/workflow.md" DestSubPath=".github/skills/coreex-aggregate/references/workflow.md" /> + <_AiFile Include="$(_RepoRoot).github/prompts/coreex-test-api.prompt.md" DestSubPath=".github/prompts/coreex-test-api.prompt.md" /> + <_AiFile Include="$(_RepoRoot).github/skills/coreex-test-api/SKILL.md" DestSubPath=".github/skills/coreex-test-api/SKILL.md" /> + <_AiFile Include="$(_RepoRoot).github/skills/coreex-test-api/references/workflow.md" DestSubPath=".github/skills/coreex-test-api/references/workflow.md" /> + <_AiFile Include="$(_RepoRoot).github/prompts/coreex-test-subscribe.prompt.md" DestSubPath=".github/prompts/coreex-test-subscribe.prompt.md" /> + <_AiFile Include="$(_RepoRoot).github/skills/coreex-test-subscribe/SKILL.md" DestSubPath=".github/skills/coreex-test-subscribe/SKILL.md" /> + <_AiFile Include="$(_RepoRoot).github/skills/coreex-test-subscribe/references/workflow.md" DestSubPath=".github/skills/coreex-test-subscribe/references/workflow.md" /> + <_AiFile Include="$(_RepoRoot).github/prompts/coreex-test-relay.prompt.md" DestSubPath=".github/prompts/coreex-test-relay.prompt.md" /> + <_AiFile Include="$(_RepoRoot).github/skills/coreex-test-relay/SKILL.md" DestSubPath=".github/skills/coreex-test-relay/SKILL.md" /> <_AiFile Include="$(_RepoRoot).github/skills/solution-scaffolder/SKILL.md" DestSubPath=".github/skills/solution-scaffolder/SKILL.md" /> <_AiFile Include="$(_RepoRoot).github/skills/solution-scaffolder/README.md" DestSubPath=".github/skills/solution-scaffolder/README.md" /> <_AiFile Include="$(_RepoRoot).github/skills/solution-scaffolder/assets/servicebus-config.template.json" DestSubPath=".github/skills/solution-scaffolder/assets/servicebus-config.template.json" /> @@ -273,6 +281,15 @@ + + + From b777f9d593b6bddf49c858911d0a01f8182e1fe5 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Wed, 1 Jul 2026 16:51:05 -0700 Subject: [PATCH 2/2] fix: address Copilot review feedback - note Postgres alternatives, rename misleading test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Postgres alternative comments alongside SQL Server-specific example calls (MigrateSqlServerDataAsync, ExpectSqlServerOutboxEvents, PostgresOutboxPublisher) so consumer domains on either provider copy the right helper. - Rename Unsubscribed_Error to Unsubscribed_CompletesSilently in coreex-test-subscribe workflow — the asserted behavior is silent completion (ErrorHandling.CompleteAsSilent), not an error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/skills/coreex-test-api/references/workflow.md | 2 +- .github/skills/coreex-test-relay/SKILL.md | 2 +- .github/skills/coreex-test-subscribe/references/workflow.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/skills/coreex-test-api/references/workflow.md b/.github/skills/coreex-test-api/references/workflow.md index a63f7cee..43f345bb 100644 --- a/.github/skills/coreex-test-api/references/workflow.md +++ b/.github/skills/coreex-test-api/references/workflow.md @@ -67,7 +67,7 @@ public partial class EmployeeMutateTests : WithApiTester [OneTimeSetUp] public async Task OneTimeSetUpAsync() { - await Test.MigrateSqlServerDataAsync(["mutate-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.MigrateSqlServerDataAsync(["mutate-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); // or MigratePostgresDataAsync(...) — provider-specific await Test.ClearFusionCacheAsync().ConfigureAwait(false); Test.UseExpectedSqlServerOutboxPublisher(); // or UseExpectedPostgresOutboxPublisher() — provider-specific } diff --git a/.github/skills/coreex-test-relay/SKILL.md b/.github/skills/coreex-test-relay/SKILL.md index 407f6f6e..9430067d 100644 --- a/.github/skills/coreex-test-relay/SKILL.md +++ b/.github/skills/coreex-test-relay/SKILL.md @@ -45,7 +45,7 @@ public class RelayTests : WithApiTester { test.Run(async _ => { - var pub = ActivatorUtilities.GetServiceOrCreateInstance(test.Services); + var pub = ActivatorUtilities.GetServiceOrCreateInstance(test.Services); // or SqlServerOutboxPublisher — provider-specific pub.Add("contoso", [ce1, ce2]); await pub.PublishAsync(); diff --git a/.github/skills/coreex-test-subscribe/references/workflow.md b/.github/skills/coreex-test-subscribe/references/workflow.md index 9a9cf478..ef70ade3 100644 --- a/.github/skills/coreex-test-subscribe/references/workflow.md +++ b/.github/skills/coreex-test-subscribe/references/workflow.md @@ -30,7 +30,7 @@ public partial class SubscriberTests : WithApiTester<{Domain}.Subscribe.Program> { // Always specify the seed file explicitly — read-data.seed.yaml, mutate-data.seed.yaml, // or a schema-only no-data.seed.yaml for plumbing/health-only tests. - await Test.MigrateSqlServerDataAsync(["mutate-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); + await Test.MigrateSqlServerDataAsync(["mutate-data.seed.yaml"], DbMigration.ConfigureMigrationArgs).ConfigureAwait(false); // or MigratePostgresDataAsync(...) — provider-specific await Test.ClearFusionCacheAsync().ConfigureAwait(false); Test.UseExpectedSqlServerOutboxPublisher(); // or UseExpectedPostgresOutboxPublisher() — provider-specific @@ -76,7 +76,7 @@ public void {Entity}{Action}_Success() => Test.Scoped(async test => items.Should().HaveCount(3); // Act — simulate the command message; assert any resulting outbox events. - test.ExpectSqlServerOutboxEvents(e => e.AssertCount(3)) + test.ExpectSqlServerOutboxEvents(e => e.AssertCount(3)) // or ExpectPostgresOutboxEvents(...) — provider-specific .Run(async _ => { var ed = EventData.CreateCommand("{domain}", "{entity}", "{action}").WithKey(referenceId); @@ -163,7 +163,7 @@ behavior worth asserting once per Subscribe host: ```csharp [Test] -public void Unsubscribed_Error() => Test.Scoped(test => +public void Unsubscribed_CompletesSilently() => Test.Scoped(test => { var ed = EventData.CreateEvent("test", "not-subscribed").WithKey("abc"); var ce = Test.CreateCloudEventFrom(ed);