Appearance
AppsPortal E2E Tests
HTTP-level tests for the AppsPortal API. Requests go through the real pipeline — routing, authentication, authorization middleware, validation, handlers, EF Core, SQL — so a passing test proves the wiring, not just the handler.
This is one of two host-specific E2E projects; see Test/E2E/README.md for the overall layout, including why Inventory routes live in a separate sibling project (Inventory.E2E.Tests) instead of here. Shared auth/seeding infrastructure (TestUser, TestAuthHandler, BaselineSeed) lives in Shared.E2E.Infrastructure and is using Shared.E2E.Infrastructure; in every test file here.
Design rationale and roadmap: Docs/AppsPortal-E2E-Testing-Plan.md. Scenario coverage checklist (what's tested vs. still missing, across BOTH E2E projects): ../COVERAGE.md.
Running
Requires Docker only (SQL Server + Redis run as throw-away Testcontainers). No localhost SQL/Redis/RabbitMQ/Mongo/Seq needed.
bash
dotnet test Test/E2E/AppsPortal.E2E.Tests/AppsPortal.E2E.Tests.csprojArchitecture (one host per run)
| Piece | File | What it does |
|---|---|---|
AppsPortalTestHost | Infrastructure/AppsPortalTestHost.cs | The single fixture. Starts SQL + Redis containers, boots the real Program in the IntegrationTest environment, and declares every real-vs-fake decision in one place. |
E2ETestBase | Infrastructure/E2ETestBase.cs | Inherit this. Joins the shared collection and, before every test, wipes the DB (Respawn) and re-seeds the baseline so each test starts identical. |
TestAuthHandler | Shared.E2E.Infrastructure project | Replaces Keycloak JWT. A request with an X-Test-User header is authenticated as that persona; without it → 401. Shared with Inventory.E2E.Tests. |
TestUser | Shared.E2E.Infrastructure project | Personas with well-known tenant/company/branch IDs. Shared. |
BaselineSeed | Shared.E2E.Infrastructure project | Builds the shared master dataset; re-applied on every reset. Exposes readable Baseline.* constants and generated ids via BaselineData. Shared — identical seed data regardless of which host is under test, since seeding writes through MicrotecAppsDBContext directly, never HTTP. |
DatabaseViews | Shared.E2E.Infrastructure project | Creates the handful of raw-SQL CREATE VIEW read models (VGeneralSetting, ...) that EnsureCreatedAsync() doesn't generate. Called once per host boot, right after EnsureCreatedAsync(). |
Real vs. faked
| Real | Faked / replaced |
|---|---|
| Full middleware pipeline, controllers, handlers | Keycloak → TestAuthHandler |
SQL Server (container), schema via EnsureCreated | RabbitMQ → MassTransit in-memory test harness |
| Redis (container) — cache & distributed locks | Tenancy CS lookup → container DB (IConnectionStringProvider/ITenantProvider) |
| EF Core, repositories, UoW | IBusinessOwnerPublicApi → fake granting all modules |
Hangfire — skipped by the app itself in IntegrationTest env | |
| Seq / OpenTelemetry — disabled via config |
Writing a test
Minimal shape:
csharp
public sealed class MyFeatureTests(AppsPortalTestHost host) : E2ETestBase(host)
{
[Fact]
public async Task My_endpoint_works()
{
using var client = Host.CreateClientFor(TestUser.Default);
var response = await client.GetAsync("/SalesInvoice");
response.EnsureSuccessStatusCode();
}
}Step-by-step: adding a new scenario
Create a test class in
Tests/, inheritE2ETestBase(host), takeAppsPortalTestHost hostas a primary-constructor parameter. Inheriting the base is what resets the database (Respawn + re-seed) before your test — never skip it.Get an authenticated client with the right modules.
Host.CreateClientFor(TestUser.Default, modules…). Pass the ERP modules the flow actually needs (see Per-test module grants). Omitting a module makes the handler'sHasModuleAccesscheck return false, which usually means a step (GL journal, stock movement) is silently skipped rather than failing loudly.Find the real route. Read the controller under
Src/AppsPortal/.../AppsPortal.Apis/Controllers/. The route is[Route("[controller]")]→ the class name minusController(e.g.InvoiceController→/Invoice), plus the action's[HttpPost(...)]template. Don't guess; controller names don't always match the module (purchase invoices live onInvoiceController→/Invoice).Build the request DTO from the command + validator, not from memory. Open the
Add*Command, itsDto/*, and the FluentValidation*Validator. Every.Required()/NotEmpty()rule and every non-nullable column must be populated. Reference seeded master data throughBaseline.*constants andHost.Baseline(FK ids).Add any missing master data to
BaselineSeed(see below) — anything the handler needs to exist (accounts, settings, sequences, mappings), as opposed to per-request payload data which goes in the test.Assert against the database, not just the HTTP status. Open a scope (
Host.Services.CreateScope()), resolveMicrotecAppsDBContext, and query with.IgnoreQueryFilters()(the tenant filter won't match your no-HTTP-context scope). Assert the real effect — posted status, journal id, stock rows — so a 200 that silently did nothing still fails the test.
Adding master data to BaselineSeed
BaselineSeed.ApplyAsync builds the shared dataset. When your handler needs a row that isn't there yet:
- Add a readable constant to the
Baselinenested class (Baseline.Vendor1.Code), use it in the seed, and expose any DB-generated id onBaselineData/Host.Baseline. - Construct entities the way the domain intends. Prefer factory methods (
Customer.Create,PaymentTerm.Create,CustomerAccounting.Create); for entities with private setters use theNew<T>()+Set(...)reflection helpers already in the file. Don't fight a computed property — set its inputs (e.g. setTax.Value+Tax.TaxValueType, not the read-onlyTax.Ratio). - Save through
SaveAsync(db), neverdb.SaveChangesAsync()directly.SaveAsyncstamps every tracked entity — parents and child navigations — with the tenant scope andCreatedBybefore saving. This is mandatory (see pitfalls). - Seed in dependency order and
await SaveAsync(db)between steps when a later row needs a generated id from an earlier one (identity ints aren't known until saved).
What to avoid (hard-won pitfalls)
These are the traps that cost real time while building the first two scenarios. Read them before debugging a mysterious failure.
- Tenant-invisible seed data. Reads go through a global query filter keyed on
SubdomainandBranchId. If a seeded row (or a child row like a financial-year period or sequence detail) isn't stamped, the handler simply can't see it and you get a misleading "no open financial year" / "not found" business error, not a DB error. Always add viaAdd(db, …)+SaveAsync(db), which stamps the whole tracked graph. - The subdomain comes from the
issclaim, notsubdomain. The package resolves the tenant from the last path segment of the JWT issuer.TestAuthHandlersetsiss = https://e2e-test/realms/{subdomain}for this reason — if you add a persona, keep that claim consistent or every tenant-scoped read returns empty. - Missing
[PolicyMapper]-style authorization is fail-open, but modules are not.TestUsercarriesuser_license = "100"(Owner), which bypasses action authorization. Business-logic module checks (HasModuleAccess) are separate and come from the ERP token — grant them explicitly inCreateClientFor. - Enabling a module can break a test, not just enable it. Granting Inventory makes sales posting attempt a stock-out, which fails on an empty stock table. Grant only what the flow needs; that's why module grants are per-request.
- A 200 response can be a false pass. If the module that writes the journal/stock isn't granted, posting "succeeds" with a null journal id. Always assert the side effect (
InvoiceJournalId.ShouldNotBeNull()), not just the status code. EnsureCreatedbuilds schema only if the DB doesn't exist. Don't pre-create the database in the fixture — it silently skips table creation and every query then fails with "invalid object name". The migration chain is validated separately (its own CI job), not here.- Package/runtime version must match the runtime.
Microsoft.AspNetCore.Mvc.Testingis pinned to the same major as the target framework (currentlynet10.0). An older TestHost throwsPipeWriter … does not implement UnflushedByteswhen the app serializes a response. - Don't reintroduce localhost dependencies. Everything external is faked or containerized in
AppsPortalTestHost. If a new flow calls an infra client (notifications, templates, attachments) that isn't stubbed, add the fake to the fixture rather than pointing at a real service. - Response/API body logging is off on purpose.
AutoLog/IsAPILogEnabledare disabled inappsettings.IntegrationTest.json; re-enabling response-body logging reintroduces a TestServerPipeWriterincompatibility. - A route that 500s with "Invalid object name 'V...'" needs a view, not a table. A few read models (
VGeneralSetting,VItemVariant, ...) are keyless entities mapped to a rawCREATE VIEW, whichEnsureCreatedAsync()never generates. If a new code path hits one that isn't inDatabaseViews.CreateAllAsyncyet, add itsGenerateViewSql()call there (inShared.E2E.Infrastructure) — don't work around it per-test. - Don't add a second in-process host to this project. Booting
AppsPortal.ApisandInventory.Apisin the same process throws — both callAddMicrotecMongo(), which registers BSON serializers into MongoDB's process-static registry, and the second registration fails. Routes on a different API host belong in their own sibling test project (seeInventory.E2E.Tests), not a second fixture here.
Master data
BaselineSeed.ApplyAsync seeds a full posting-ready dataset (country, currency, company/branch, open financial year + monthly periods, sequences, GL account, tax, customer + accounting mapping, payment term, item + variant/UOM, warehouse, price policy, sales/accounting general settings). It runs on every reset, so each test starts from the identical baseline.
Reference the seeded rows through the readable constants on Baseline (e.g. Baseline.Item1.Code, Baseline.Customer1.NameEn, Baseline.Item1.Price) rather than magic strings. Database-generated Ids (needed for FK payload fields) are returned on BaselineData and exposed as Host.Baseline.
Scenarios
SalesInvoiceScenarioTests— POST a sales invoice, post it, assert posted state + journal. Proves the whole pipeline (auth → API key → ERP token → validation → handler → EF → GL).PurchaseInvoiceScenarioTests— POST a purchase invoice, post it, assert posted state + journal. Grants Purchase + Inventory so posting performs the stock-in.SalesReturnInvoiceScenarioTests/PurchaseReturnInvoiceScenarioTests— post an original invoice first (reusing the other scenario'sBuildAddCommand, exposedinternal), then create+post a return against one of its lines.PaymentInScenarioTests/PaymentOutScenarioTests— standalone Treasury receipts/ payments (not tied to an invoice).FundTransferScenarioTests— Bank→Bank transfer between two accounts of the same seeded bank.
Inventory scenarios (Stock In/Out/Transfer, Count, Inv Opening Balance) live in the sibling Inventory.E2E.Tests project — see its own README.
Per-test module grants
CreateClientFor(user, modules…) encodes the granted ERP modules into the X-Token-Version header, and the stubbed IErpTokenService decodes them per request. Pass exactly the modules a scenario needs: the sales scenario omits Inventory (so posting skips stock-out on an empty stock table), the purchase scenario includes it. Default when omitted: Accounting + Sales.
Roadmap (from the plan)
- [x] Seed the shared baseline matching
TestUser.WellKnownIDs - [x] Enable ERP authorization (
[PolicyMapper]) path in tests + owner persona - [x] Golden-path scenarios: invoices, returns, Finance (Payment In/Out, Fund Transfer)
- [x] Inventory golden paths, in the sibling
Inventory.E2E.Testsproject - [ ] Scenario builders (
Scenario.SalesInvoice().Draft().BuildAsync()) - [ ] Remaining scenarios: Customer/Vendor Opening Balance, Journal Entry, Fixed Assets (see
../COVERAGE.md) - [ ] Template-backup/restore for parallel collections
- [ ] CI stage (
dotnet teston PR) + nightly full-migration-chain job