Skip to content

Commit a84e791

Browse files
authored
Merge pull request #2 from Arcoders/LASAGNA-120526/test-coverage
test: E2E
2 parents fa49152 + 28fbce5 commit a84e791

53 files changed

Lines changed: 4531 additions & 239 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.c8rc.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"all": true,
3+
"src": ["src"],
4+
"include": ["src/**/*.ts"],
5+
"exclude": [
6+
"src/**/*.d.ts",
7+
"src/commands/commands.json",
8+
"src/types/**",
9+
"build/**",
10+
"bin/**",
11+
"tests/**",
12+
"examples/**"
13+
],
14+
"reporter": ["text-summary", "lcov"],
15+
"report-dir": "./coverage",
16+
"clean": true,
17+
"check-coverage": false,
18+
"lines": 0,
19+
"branches": 0,
20+
"functions": 0,
21+
"statements": 0
22+
}

.github/workflows/ci.yml

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,16 @@ jobs:
3333
- name: Build
3434
run: npm run build
3535

36-
- name: Test (unit)
37-
run: npm test
36+
- name: Test (unit) + coverage
37+
run: npm run test:coverage
38+
39+
- name: Upload unit coverage
40+
if: always()
41+
uses: actions/upload-artifact@v4
42+
with:
43+
name: coverage-unit
44+
path: coverage/lcov.info
45+
if-no-files-found: warn
3846

3947
# Surfaces unused exports / orphaned files / unused deps. `--no-exit-code`
4048
# so the report is informational; flip to required once the baseline
@@ -76,6 +84,13 @@ jobs:
7684
--health-timeout 5s
7785
--health-retries 10
7886
87+
# Wire-compliant OIDC server for sso_oidc_real.spec.ts.
88+
# (MinIO can't run here — Actions services don't allow CMD overrides.)
89+
mock-oidc:
90+
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
91+
ports:
92+
- 8080:8080
93+
7994
env:
8095
TZ: UTC
8196
NODE_ENV: test
@@ -97,6 +112,18 @@ jobs:
97112
CACHE_REDIS_HOST: 127.0.0.1
98113
CACHE_REDIS_PORT: 6379
99114
CACHE_REDIS_DB: 2
115+
# Optional Stripe test-mode key (sk_test_…) for stripe_real_smoke.spec.ts.
116+
# Unset → spec skips visibly. Add via Settings → Secrets → Actions.
117+
STRIPE_TEST_API_KEY: ${{ secrets.STRIPE_TEST_API_KEY }}
118+
# MinIO (S3) for backup_s3.spec.ts. Unset → spec skips.
119+
BACKUP_S3_ENDPOINT: http://127.0.0.1:9000
120+
BACKUP_S3_BUCKET: lasagna-test
121+
BACKUP_S3_REGION: us-east-1
122+
AWS_ACCESS_KEY_ID: minioadmin
123+
AWS_SECRET_ACCESS_KEY: minioadmin
124+
# mock-oauth2-server for sso_oidc_real.spec.ts. `default` = mock's
125+
# placeholder issuer path for unspecified issuers.
126+
MOCK_OIDC_BASE_URL: http://127.0.0.1:8080/default
100127

101128
steps:
102129
- uses: actions/checkout@v4
@@ -111,8 +138,43 @@ jobs:
111138
npm_config_ignore_scripts: 'false'
112139
run: npm install --legacy-peer-deps --include=optional
113140

114-
- name: Test (integration)
115-
run: npm run test:integration
141+
# Run MinIO directly (Actions services can't pass `server /data`).
142+
- name: Start MinIO (S3-compatible)
143+
run: |
144+
docker run -d --rm --name lasagna-minio \
145+
-p 9000:9000 \
146+
-e MINIO_ROOT_USER=minioadmin \
147+
-e MINIO_ROOT_PASSWORD=minioadmin \
148+
minio/minio:latest server /data
149+
for i in 1 2 3 4 5 6 7 8 9 10; do
150+
if curl -fsS http://127.0.0.1:9000/minio/health/ready > /dev/null; then
151+
echo "minio ready"; exit 0
152+
fi
153+
sleep 1
154+
done
155+
echo "minio failed to become ready"; docker logs lasagna-minio; exit 1
156+
157+
# Fail fast if the mock-oidc service container didn't come up.
158+
- name: Probe mock-oauth2-server
159+
run: |
160+
for i in 1 2 3 4 5 6 7 8 9 10; do
161+
if curl -fsS "$MOCK_OIDC_BASE_URL/.well-known/openid-configuration" > /dev/null; then
162+
echo "mock-oidc ready"; exit 0
163+
fi
164+
sleep 1
165+
done
166+
echo "mock-oidc failed to become ready"; exit 1
167+
168+
- name: Test (integration) + coverage
169+
run: npm run test:integration:coverage
170+
171+
- name: Upload integration coverage
172+
if: always()
173+
uses: actions/upload-artifact@v4
174+
with:
175+
name: coverage-integration
176+
path: coverage/lcov.info
177+
if-no-files-found: warn
116178

117179
test-e2e-demo:
118180
name: E2E (demo app)

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
node_modules/
22
build/
3+
coverage/
4+
.nyc_output/
35
.env
46
*.log
57
.claude

CHANGELOG.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,94 @@ This project adheres to [Semantic Versioning](https://semver.org/).
66

77
---
88

9+
## [0.2.1] — 2026-05-17
10+
11+
Hardening release. Three production-affecting bug fixes uncovered by
12+
a test-coverage audit, plus a substantial integration + E2E expansion
13+
(now 505 unit + 355 integration + 123 e2e).
14+
15+
### Fixed
16+
17+
- **Package queue jobs are now resolvable by `queue:work`**.
18+
`MultitenancyProvider.boot()` registers `InstallTenant`,
19+
`UninstallTenant`, `CloneTenant`, `BackupTenant`, `RestoreTenant`,
20+
`ProcessStripeEventJob`, `BillingCleanupJob`, and `ReportUsageBatchJob`
21+
with `@adonisjs/queue`'s `Locator`. Pre-fix, host apps' job
22+
auto-discovery (`app/jobs/**`) didn't reach into `node_modules`, so
23+
any dispatched package job was dead-lettered at the worker. Jobs are
24+
registered (and dispatched) under `lasagna.<JobName>` so a host
25+
app's same-named job can't collide.
26+
- **`CloneService` integer-sequence reset now actually runs**.
27+
`#resetIntegerSequences` was passing `$1`/`$2` bindings to
28+
`trx.rawQuery()`; Knex rejects those (it expects `?`), but the
29+
failure was swallowed by the surrounding savepoint rollback.
30+
Result: every cloned tenant inherited its source's sequence, so the
31+
next insert PK-collided with a copied row. Identifiers are now
32+
interpolated directly into the SQL (guarded by
33+
`assertSafeIdentifier` upstream).
34+
- **`ImpersonationMiddleware` constructor no longer breaks IoC
35+
resolution**. The optional typed constructor parameter forced the
36+
AdonisJS container to try injecting `ImpersonationService` at
37+
middleware resolution time, which it can't (the service needs a
38+
config-validated boot). Refactored to a `protected getService()`
39+
seam that subclasses can override for tests.
40+
41+
### Changed
42+
43+
- **Build artefact no longer embeds TypeScript source**.
44+
`inlineSources: true` removed from `tsconfig.json`. `.js.map` files
45+
in `build/` still reference `.ts` paths for stack traces but no
46+
longer carry the full source bytes — smaller install footprint for
47+
consumers.
48+
- **Test coverage tooling**. Added `c8` with `test:coverage` /
49+
`test:integration:coverage` scripts and `.c8rc.json` (thresholds
50+
report-only at 0; ratchet after a baseline is captured). CI uploads
51+
`lcov.info` as an artifact.
52+
- **CI provisions MinIO + mock-oauth2-server** so the new S3 / OIDC
53+
integration specs run against real backends. Optional
54+
`STRIPE_TEST_API_KEY` secret enables the Stripe live-API smoke
55+
test; without it the spec reports itself skipped.
56+
57+
### Test coverage
58+
59+
Closing the gap between "lots of tests" and "production confidence".
60+
New end-to-end coverage for previously-thin paths:
61+
62+
- `examples/api/tests/e2e/commands_lifecycle.spec.ts` (10 tests) —
63+
real ace command execution for `tenant:list`/`suspend`/`activate`/
64+
`import`/`purge-expired`/`maintenance`/`impersonate`/`backups:run`/
65+
`webhooks:retry`.
66+
- `examples/api/tests/e2e/queue_jobs.spec.ts` (2 tests) — real
67+
`queue:work` subprocess provisioning and tearing down tenants.
68+
- `tests/integration/services/backup_s3.spec.ts` (real MinIO),
69+
`sso_oidc_real.spec.ts` (real `mock-oauth2-server`),
70+
`stripe_real_smoke.spec.ts` (real Stripe test API).
71+
- `tests/integration/middleware/rate_limit.spec.ts` (6 tests, real
72+
Redis pipeline) and `impersonation_middleware.spec.ts` (real HTTP +
73+
Redis).
74+
- `tests/integration/services/bootstrapper_isolation.spec.ts` (9
75+
tests, cross-tenant isolation + 16-way `AsyncLocalStorage`
76+
concurrency), `clone_service.spec.ts`, `doctor_checks_real.spec.ts`,
77+
`telemetry_export.spec.ts` (with real OTel SDK +
78+
`AsyncLocalStorageContextManager`).
79+
- `tests/integration/billing/diagnostics_commands.spec.ts`
80+
(`tenant:billing:doctor` + `tenant:billing:test-webhook`).
81+
- `tests/integration/billing/stripe_real_smoke.spec.ts` expanded to
82+
cover every Stripe SDK call-site.
83+
84+
### Upgrade notes
85+
86+
- **Drain your queue before upgrading** if you have unprocessed
87+
package jobs in Redis under the old names (`InstallTenant`,
88+
`UninstallTenant`, etc.). After upgrade, the worker resolves them
89+
under `lasagna.<JobName>`, so any pending pre-upgrade jobs will
90+
dead-letter at the worker.
91+
- **Subclassers of `ImpersonationMiddleware`**: the constructor
92+
parameter is gone. Override `getService()` instead of injecting via
93+
`new ImpersonationMiddleware(service)`.
94+
95+
---
96+
997
## [0.2.0] — 2026-05-09
1098

1199
Adds the **Stripe billing satellite** as the ninth opt-in feature.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ flags, metrics, and Stripe billing.
2121
[![AdonisJS](https://img.shields.io/badge/AdonisJS-7-5a45ff)](https://adonisjs.com)
2222
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-%E2%89%A514-336791)](https://www.postgresql.org)
2323
[![Redis](https://img.shields.io/badge/Redis-%E2%89%A56-DC382D)](https://redis.io)
24-
[![Tests](https://img.shields.io/badge/tests-404%20unit%20%2B%20105%20integration%20%2B%20111%20e2e-brightgreen)](./tests)
24+
[![Tests](https://img.shields.io/badge/tests-505%20unit%20%2B%20355%20integration%20%2B%20123%20e2e-brightgreen)](./tests)
2525
[![Docs](https://img.shields.io/badge/docs-published-C26A4B)](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/)
2626
[![License](https://img.shields.io/badge/License-MIT-blue)](./LICENSE)
2727

@@ -36,7 +36,7 @@ nothing leaked, you already know the problem this solves.
3636
If you'd rather see it run than read about it, jump to
3737
[examples/api/](examples/api/). It's a real AdonisJS 7 app that
3838
exercises every feature, and one `npm run test:e2e` brings up the stack
39-
and runs 111 tests against it.
39+
and runs 123 tests against it.
4040

4141
🔒 **Audited against real production failure modes** — cross-tenant isolation under concurrency, quota atomicity, SSO replay, audit immutability, header-vs-domain hijack, and more. [See what we verified →](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/why#hardened-against-the-failures-that-bite-you-in-production)
4242

@@ -112,7 +112,7 @@ docker compose -f compose.test.yml up -d
112112
npm run test:e2e
113113
```
114114

115-
The 111-test e2e suite covers provisioning, schema isolation,
115+
The 123-test e2e suite covers provisioning, schema isolation,
116116
contextual logging across HTTP + queue, the doctor command, backups +
117117
restore + clone, quotas → 429, lifecycle events, the admin REST API,
118118
mail context propagation, replica routing, and the webhook delivery

bin/test.integration.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,27 @@ const FIXTURE_ROOT = new URL('../tests/fixtures/', import.meta.url)
1616
// passed. Swallow this *specific* error only after we've started the
1717
// shutdown handshake; anything else still surfaces as a real failure.
1818
let isShuttingDown = false
19-
process.on('unhandledRejection', (reason) => {
19+
const isConnectionTerminated = (reason: unknown): boolean => {
2020
const message =
2121
reason instanceof Error
2222
? reason.message
2323
: typeof reason === 'string'
2424
? reason
2525
: ''
26-
if (isShuttingDown && /^Connection terminated/.test(message)) {
27-
return
28-
}
26+
return /^Connection terminated/.test(message)
27+
}
28+
process.on('unhandledRejection', (reason) => {
29+
if (isShuttingDown && isConnectionTerminated(reason)) return
2930
throw reason
3031
})
32+
// Same root cause as the unhandledRejection above, but pg surfaces it
33+
// via the Client's `'error'` event when no query is queued — with no
34+
// listener, Node escalates to uncaughtException and the process exits 1
35+
// even though every test passed.
36+
process.on('uncaughtException', (err) => {
37+
if (isShuttingDown && isConnectionTerminated(err)) return
38+
throw err
39+
})
3140

3241
const IMPORTER = (filePath: string) => {
3342
if (filePath.startsWith('./') || filePath.startsWith('../')) {

docs/.vitepress/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export default defineConfig({
9191
{ text: 'Showcase', link: '/showcase' },
9292
{ text: 'Sponsor', link: '/sponsor' },
9393
{
94-
text: 'v0.2.0',
94+
text: 'v0.2.1',
9595
items: [
9696
{ text: 'Changelog', link: `${REPO}/blob/master/CHANGELOG.md` },
9797
{ text: 'Release notes', link: '/docs/release-notes' },

docs/.vitepress/theme/components/HomeLayered.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const pillars: Pillar[] = [
6868
<section class="hl-hero">
6969
<div class="hl-hero__copy">
7070
<p class="hl-eyebrow">
71-
<PhStar :size="14" weight="fill" /> AdonisJS · v0.2.0
71+
<PhStar :size="14" weight="fill" /> AdonisJS · v0.2.1
7272
</p>
7373
<h1 class="hl-title">
7474
The only multi-tenant layer

docs/data/comparison.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "comparison.schema",
33
"package": "@adonisjs-lasagna/saas-tenancy",
4-
"version": "0.2.0",
4+
"version": "0.2.1",
55
"compared": "stancl/tenancy v3 (Tenancy for Laravel)",
66
"categories": [
77
{ "id": "identification", "label": "Tenant identification" },

0 commit comments

Comments
 (0)