Skip to content

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.csproj

Architecture (one host per run)

PieceFileWhat it does
AppsPortalTestHostInfrastructure/AppsPortalTestHost.csThe single fixture. Starts SQL + Redis containers, boots the real Program in the IntegrationTest environment, and declares every real-vs-fake decision in one place.
E2ETestBaseInfrastructure/E2ETestBase.csInherit this. Joins the shared collection and, before every test, wipes the DB (Respawn) and re-seeds the baseline so each test starts identical.
TestAuthHandlerShared.E2E.Infrastructure projectReplaces Keycloak JWT. A request with an X-Test-User header is authenticated as that persona; without it → 401. Shared with Inventory.E2E.Tests.
TestUserShared.E2E.Infrastructure projectPersonas with well-known tenant/company/branch IDs. Shared.
BaselineSeedShared.E2E.Infrastructure projectBuilds 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.
DatabaseViewsShared.E2E.Infrastructure projectCreates 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

RealFaked / replaced
Full middleware pipeline, controllers, handlersKeycloak → TestAuthHandler
SQL Server (container), schema via EnsureCreatedRabbitMQ → MassTransit in-memory test harness
Redis (container) — cache & distributed locksTenancy CS lookup → container DB (IConnectionStringProvider/ITenantProvider)
EF Core, repositories, UoWIBusinessOwnerPublicApi → 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

  1. Create a test class in Tests/, inherit E2ETestBase(host), take AppsPortalTestHost host as a primary-constructor parameter. Inheriting the base is what resets the database (Respawn + re-seed) before your test — never skip it.

  2. 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's HasModuleAccess check return false, which usually means a step (GL journal, stock movement) is silently skipped rather than failing loudly.

  3. Find the real route. Read the controller under Src/AppsPortal/.../AppsPortal.Apis/Controllers/. The route is [Route("[controller]")] → the class name minus Controller (e.g. InvoiceController/Invoice), plus the action's [HttpPost(...)] template. Don't guess; controller names don't always match the module (purchase invoices live on InvoiceController/Invoice).

  4. Build the request DTO from the command + validator, not from memory. Open the Add*Command, its Dto/*, and the FluentValidation *Validator. Every .Required() / NotEmpty() rule and every non-nullable column must be populated. Reference seeded master data through Baseline.* constants and Host.Baseline (FK ids).

  5. 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.

  6. Assert against the database, not just the HTTP status. Open a scope (Host.Services.CreateScope()), resolve MicrotecAppsDBContext, 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 Baseline nested class (Baseline.Vendor1.Code), use it in the seed, and expose any DB-generated id on BaselineData / Host.Baseline.
  • Construct entities the way the domain intends. Prefer factory methods (Customer.Create, PaymentTerm.Create, CustomerAccounting.Create); for entities with private setters use the New<T>() + Set(...) reflection helpers already in the file. Don't fight a computed property — set its inputs (e.g. set Tax.Value + Tax.TaxValueType, not the read-only Tax.Ratio).
  • Save through SaveAsync(db), never db.SaveChangesAsync() directly. SaveAsync stamps every tracked entity — parents and child navigations — with the tenant scope and CreatedBy before 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 Subdomain and BranchId. 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 via Add(db, …) + SaveAsync(db), which stamps the whole tracked graph.
  • The subdomain comes from the iss claim, not subdomain. The package resolves the tenant from the last path segment of the JWT issuer. TestAuthHandler sets iss = 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.TestUser carries user_license = "100" (Owner), which bypasses action authorization. Business-logic module checks (HasModuleAccess) are separate and come from the ERP token — grant them explicitly in CreateClientFor.
  • 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.
  • EnsureCreated builds 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.Testing is pinned to the same major as the target framework (currently net10.0). An older TestHost throws PipeWriter … does not implement UnflushedBytes when 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/IsAPILogEnabled are disabled in appsettings.IntegrationTest.json; re-enabling response-body logging reintroduces a TestServer PipeWriter incompatibility.
  • 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 raw CREATE VIEW, which EnsureCreatedAsync() never generates. If a new code path hits one that isn't in DatabaseViews.CreateAllAsync yet, add its GenerateViewSql() call there (in Shared.E2E.Infrastructure) — don't work around it per-test.
  • Don't add a second in-process host to this project. Booting AppsPortal.Apis and Inventory.Apis in the same process throws — both call AddMicrotecMongo(), 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 (see Inventory.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's BuildAddCommand, exposed internal), 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.WellKnown IDs
  • [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.Tests project
  • [ ] 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 test on PR) + nightly full-migration-chain job

Internal Documentation — Microtec