openvibers/openvibe.coupons Commits
Move Coupons errors and shutdown onto openvibe-sdk/service (#3)
Changes
@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0"},@@@ -1181,9 +1181,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.25.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",-- "integrity": "sha512-c6K1riU7xT60qEd0YFn4QDPjWXTglo0LcE4cU8TSrmRTJL7TPAT7/iqQjS2UACt2zmQ1soNM8gfIZf7v9sVdZg==",++ "version": "0.26.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",++ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==","license": "MIT","engines": {"node": ">=20"@@@ -28,7 +28,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0"},@@@ -2,22 +2,18 @@/*** Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),-- * and the small request helpers every router shares.++ * and the small request helpers every router shares. Built from openvibe-sdk/service (docs/service.md,++ * the Blog row) so every service answers the same way; the exports stay put so no call site moves.++ * Only an ApiError or a HostError is a refusal: the SDK's default would also echo any error carrying++ * { status, code }, so anything else reaches it stripped to its stack and answers a generic 500.*/--const express = require('express');--const contracts = require('openvibe-contracts');++const svc = require('openvibe-sdk/service');const cache = require('openvibe-shared/cache-policy');/** A refusal with a stable problem code (e.g. 404 'coupon.not_found'). */--class ApiError extends Error {-- constructor(status, code, detail, extra) {-- super(detail || code);-- this.name = 'ApiError';-- this.status = status;-- this.code = code;-- this.extra = extra || null;-- }--}++const ApiError = svc.createServiceError('ApiError');++++const o = { name: 'Coupons API' };/** Domain errors (HostError, …) carry { status, code } too. */function asApiError(err) {@@@ -26,28 +22,20 @@ function asApiError(err) {return null;}++/** An unexpected error with nothing a client may see: logged by its stack, answered as internal.error. */++const internal = (err) => ({ stack: err && err.stack ? err.stack : String(err) });++/** Wrap a JSON handler: its return value is the body; errors become problems. */--function run(fn, status = 200) {-- return async (req, res) => {-- try {-- const out = await fn(req, res);-- if (out === undefined || res.headersSent) return;-- res.status(typeof status === 'function' ? status(out) : status).json(out);-- } catch (err) {-- if (res.headersSent) return;-- const e = asApiError(err);-- if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });-- console.error('[Coupons API]', err && err.stack ? err.stack : err);-- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });-- }-- };--}++const run = (fn, status) => svc.run(async (req, res) => {++ try {++ return await fn(req, res);++ } catch (err) {++ throw asApiError(err) || internal(err);++ }++}, status, o);--const jsonParser = express.json({ limit: '32kb' });--/** JSON body parser whose syntax errors are problems too. */--function jsonBody(req, res, next) {-- jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));--}++/** JSON body parser whose failures are problems too: malformed 400, over the limit 413. */++const jsonBody = svc.jsonBody({ limit: '32kb' });/** Private, per-viewer responses: never stored by a shared cache, never indexed. */function privateNoStore(res) {@@@ -6,29 +6,40 @@* client secret are set) and the worker (expiry/decay sweep; Sources import when configured).*/const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');--(async () => {--const { app, ctx } = await createApp();--const { config } = ctx;++/**++ * The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new++ * work, the HTTP drain runs (defaults 4000/5000), then the outbox settles and the store closes; past++ * the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject++ * `exit` and `signals: false`.++ */++function createLifecycle({ server, ctx, exit, signals }) {++ return gracefulStop({++ name: 'Coupons', server, deadlineExitCode: 0, exit, signals,++ stop: [() => ctx.worker.stop()],++ close: [() => ctx.outbox.stop(), () => ctx.store.close()],++ });++}--const server = app.listen(config.port, config.host, () => {-- console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);-- console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);--});--server.keepAliveTimeout = 65_000;--ctx.outbox.start();--ctx.worker.start();++async function start() {++ const { app, ctx } = await createApp();++ const { config } = ctx;--function shutdown(signal) {-- console.log(`[Coupons] ${signal}: closing`);-- ctx.worker.stop();-- server.close(async () => {-- try { await ctx.outbox.stop(); } catch { /* best effort */ }-- try { await ctx.store.close(); } catch { /* already closed */ }-- process.exit(0);++ const server = app.listen(config.port, config.host, () => {++ console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);++ console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);});-- setTimeout(() => process.exit(0), 5000).unref();++ server.keepAliveTimeout = 65_000;++ ctx.outbox.start();++ ctx.worker.start();++++ createLifecycle({ server, ctx });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[Coupons] failed to start:', err); process.exit(1); });}--process.on('SIGTERM', () => shutdown('SIGTERM'));--process.on('SIGINT', () => shutdown('SIGINT'));--})().catch((err) => { console.error('[Coupons] failed to start:', err); process.exit(1); });++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * openvibe-sdk/service (plan T1): the shared JSON body parser answers 413 for a body over 32 kB++ * (the hand-rolled one answered 400 request.invalid_json there), and the entry point's graceful stop++ * runs its stop and close steps and exits 0. Only an ApiError or a HostError is a refusal: any other error++ * answers a generic 500, even one carrying { status, code }.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++const express = require('express');++const { createLifecycle } = require('../server/index');++const errors = require('../server/http/errors');++++(async () => {++ const t = await boot();++++ await check('a JSON body over 32 kB is 413 request.too_large, not 400 request.invalid_json', async () => {++ const r = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'Big', domains: [{ host: 'big.example' }], padding: 'y'.repeat(40 * 1024) } });++ assert.strictEqual(r.status, 413, r.text);++ assert.strictEqual(r.json().code, 'request.too_large');++ });++++ await check('an error that is not an ApiError or a HostError never reaches the client, even with { status, code }', async () => {++ const app = express();++ app.get('/x', errors.run(() => { const e = new Error('secret internals'); e.status = 409; e.code = 'db.conflict'; throw e; }));++ app.get('/h', errors.run(() => { const e = new Error('bad host'); e.name = 'HostError'; e.status = 422; e.code = 'host.invalid'; throw e; }));++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const log = console.error;++ console.error = () => {};++ try {++ const leak = await fetch(`${base}/x`);++ assert.strictEqual(leak.status, 500);++ const body = await leak.json();++ assert.strictEqual(body.code, 'internal.error');++ assert.ok(!JSON.stringify(body).includes('secret'), JSON.stringify(body));++ const host = await fetch(`${base}/h`);++ assert.strictEqual(host.status, 422);++ assert.strictEqual((await host.json()).code, 'host.invalid');++ } finally {++ console.error = log;++ server.close();++ }++ });++++ await check('the entry point stop runs its stop and close steps, then exits 0', async () => {++ const steps = [];++ const spy = (obj, method, label) => {++ const orig = obj[method].bind(obj);++ obj[method] = (...a) => { steps.push(label); return orig(...a); };++ };++ spy(t.ctx.worker, 'stop', 'worker.stop');++ spy(t.ctx.outbox, 'stop', 'outbox.stop');++ spy(t.ctx.store, 'close', 'store.close');++++ const server = http.createServer(t.app);++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++++ const exits = [];++ const lifecycle = createLifecycle({ server, ctx: t.ctx, exit: (code) => exits.push(code), signals: false });++ const code = await lifecycle.stop('SIGTERM');++++ assert.strictEqual(code, 0);++ assert.deepStrictEqual(exits, [0]);++ assert.deepStrictEqual(steps, ['worker.stop', 'outbox.stop', 'store.close']);++ assert.strictEqual(server.listening, false);++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1181,9 +1181,9 @@
@@ -1181,9 +1181,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.25.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
- "integrity": "sha512-c6K1riU7xT60qEd0YFn4QDPjWXTglo0LcE4cU8TSrmRTJL7TPAT7/iqQjS2UACt2zmQ1soNM8gfIZf7v9sVdZg==",
+ "version": "0.26.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
+ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -2,22 +2,18 @@
@@ -2,22 +2,18 @@
/**
/**
* Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
* Errors as RFC 9457 problems (contracts errors.problem@1, which keeps the legacy { error } field),
- * and the small request helpers every router shares.
+ * and the small request helpers every router shares. Built from openvibe-sdk/service (docs/service.md,
+ * the Blog row) so every service answers the same way; the exports stay put so no call site moves.
+ * Only an ApiError or a HostError is a refusal: the SDK's default would also echo any error carrying
+ * { status, code }, so anything else reaches it stripped to its stack and answers a generic 500.
*/
*/
-const express = require('express');
-const contracts = require('openvibe-contracts');
+const svc = require('openvibe-sdk/service');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
/** A refusal with a stable problem code (e.g. 404 'coupon.not_found'). */
/** A refusal with a stable problem code (e.g. 404 'coupon.not_found'). */
-class ApiError extends Error {
- constructor(status, code, detail, extra) {
- super(detail || code);
- this.name = 'ApiError';
- this.status = status;
- this.code = code;
- this.extra = extra || null;
- }
-}
+const ApiError = svc.createServiceError('ApiError');
+
+const o = { name: 'Coupons API' };
/** Domain errors (HostError, …) carry { status, code } too. */
/** Domain errors (HostError, …) carry { status, code } too. */
function asApiError(err) {
function asApiError(err) {
@@ -26,28 +22,20 @@ function asApiError(err) {
@@ -26,28 +22,20 @@ function asApiError(err) {
return null;
return null;
}
}
+/** An unexpected error with nothing a client may see: logged by its stack, answered as internal.error. */
+const internal = (err) => ({ stack: err && err.stack ? err.stack : String(err) });
+
/** Wrap a JSON handler: its return value is the body; errors become problems. */
/** Wrap a JSON handler: its return value is the body; errors become problems. */
-function run(fn, status = 200) {
- return async (req, res) => {
- try {
- const out = await fn(req, res);
- if (out === undefined || res.headersSent) return;
- res.status(typeof status === 'function' ? status(out) : status).json(out);
- } catch (err) {
- if (res.headersSent) return;
- const e = asApiError(err);
- if (e) return contracts.http.sendProblem(res, e.status, e.code, { detail: e.message, ctx: req.ov, extra: e.extra || undefined });
- console.error('[Coupons API]', err && err.stack ? err.stack : err);
- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
- }
- };
-}
+const run = (fn, status) => svc.run(async (req, res) => {
+ try {
+ return await fn(req, res);
+ } catch (err) {
+ throw asApiError(err) || internal(err);
+ }
+}, status, o);
-const jsonParser = express.json({ limit: '32kb' });
-/** JSON body parser whose syntax errors are problems too. */
-function jsonBody(req, res, next) {
- jsonParser(req, res, (err) => (err ? contracts.http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
-}
+/** JSON body parser whose failures are problems too: malformed 400, over the limit 413. */
+const jsonBody = svc.jsonBody({ limit: '32kb' });
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
/** Private, per-viewer responses: never stored by a shared cache, never indexed. */
function privateNoStore(res) {
function privateNoStore(res) {
@@ -6,29 +6,40 @@
@@ -6,29 +6,40 @@
* client secret are set) and the worker (expiry/decay sweep; Sources import when configured).
* client secret are set) and the worker (expiry/decay sweep; Sources import when configured).
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
-(async () => {
-const { app, ctx } = await createApp();
-const { config } = ctx;
+/**
+ * The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new
+ * work, the HTTP drain runs (defaults 4000/5000), then the outbox settles and the store closes; past
+ * the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject
+ * `exit` and `signals: false`.
+ */
+function createLifecycle({ server, ctx, exit, signals }) {
+ return gracefulStop({
+ name: 'Coupons', server, deadlineExitCode: 0, exit, signals,
+ stop: [() => ctx.worker.stop()],
+ close: [() => ctx.outbox.stop(), () => ctx.store.close()],
+ });
+}
-const server = app.listen(config.port, config.host, () => {
- console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
- console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);
-});
-server.keepAliveTimeout = 65_000;
-ctx.outbox.start();
-ctx.worker.start();
+async function start() {
+ const { app, ctx } = await createApp();
+ const { config } = ctx;
-function shutdown(signal) {
- console.log(`[Coupons] ${signal}: closing`);
- ctx.worker.stop();
- server.close(async () => {
- try { await ctx.outbox.stop(); } catch { /* best effort */ }
- try { await ctx.store.close(); } catch { /* already closed */ }
- process.exit(0);
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[Coupons] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
+ console.log(`[Coupons] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.importer.enabled ? 'on' : 'off'}`);
});
});
- setTimeout(() => process.exit(0), 5000).unref();
+ server.keepAliveTimeout = 65_000;
+ ctx.outbox.start();
+ ctx.worker.start();
+
+ createLifecycle({ server, ctx });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[Coupons] failed to start:', err); process.exit(1); });
}
}
-process.on('SIGTERM', () => shutdown('SIGTERM'));
-process.on('SIGINT', () => shutdown('SIGINT'));
-})().catch((err) => { console.error('[Coupons] failed to start:', err); process.exit(1); });
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * openvibe-sdk/service (plan T1): the shared JSON body parser answers 413 for a body over 32 kB
+ * (the hand-rolled one answered 400 request.invalid_json there), and the entry point's graceful stop
+ * runs its stop and close steps and exits 0. Only an ApiError or a HostError is a refusal: any other error
+ * answers a generic 500, even one carrying { status, code }.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+const express = require('express');
+const { createLifecycle } = require('../server/index');
+const errors = require('../server/http/errors');
+
+(async () => {
+ const t = await boot();
+
+ await check('a JSON body over 32 kB is 413 request.too_large, not 400 request.invalid_json', async () => {
+ const r = await t.get('/api/v1/merchants', { as: t.staffToken(), json: { name: 'Big', domains: [{ host: 'big.example' }], padding: 'y'.repeat(40 * 1024) } });
+ assert.strictEqual(r.status, 413, r.text);
+ assert.strictEqual(r.json().code, 'request.too_large');
+ });
+
+ await check('an error that is not an ApiError or a HostError never reaches the client, even with { status, code }', async () => {
+ const app = express();
+ app.get('/x', errors.run(() => { const e = new Error('secret internals'); e.status = 409; e.code = 'db.conflict'; throw e; }));
+ app.get('/h', errors.run(() => { const e = new Error('bad host'); e.name = 'HostError'; e.status = 422; e.code = 'host.invalid'; throw e; }));
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const log = console.error;
+ console.error = () => {};
+ try {
+ const leak = await fetch(`${base}/x`);
+ assert.strictEqual(leak.status, 500);
+ const body = await leak.json();
+ assert.strictEqual(body.code, 'internal.error');
+ assert.ok(!JSON.stringify(body).includes('secret'), JSON.stringify(body));
+ const host = await fetch(`${base}/h`);
+ assert.strictEqual(host.status, 422);
+ assert.strictEqual((await host.json()).code, 'host.invalid');
+ } finally {
+ console.error = log;
+ server.close();
+ }
+ });
+
+ await check('the entry point stop runs its stop and close steps, then exits 0', async () => {
+ const steps = [];
+ const spy = (obj, method, label) => {
+ const orig = obj[method].bind(obj);
+ obj[method] = (...a) => { steps.push(label); return orig(...a); };
+ };
+ spy(t.ctx.worker, 'stop', 'worker.stop');
+ spy(t.ctx.outbox, 'stop', 'outbox.stop');
+ spy(t.ctx.store, 'close', 'store.close');
+
+ const server = http.createServer(t.app);
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+
+ const exits = [];
+ const lifecycle = createLifecycle({ server, ctx: t.ctx, exit: (code) => exits.push(code), signals: false });
+ const code = await lifecycle.stop('SIGTERM');
+
+ assert.strictEqual(code, 0);
+ assert.deepStrictEqual(exits, [0]);
+ assert.deepStrictEqual(steps, ['worker.stop', 'outbox.stop', 'store.close']);
+ assert.strictEqual(server.listening, false);
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });