openvibers/openvibe.deals Commits
Move Deals 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,37 +2,32 @@/*** 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 Deals row) so every service answers the same way; the exports stay put so no call site moves.++ * ApiError stays Deals' own refusal (server/domain/values.js): the kit answers any error carrying an++ * HTTP status and a string code with its own status and code, and anything else as 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 { ApiError } = require('../domain/values');--/** 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;-- if (err instanceof ApiError) {-- if (err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));-- return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });-- }-- console.error('[Deals API]', err && err.stack ? err.stack : err);-- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });-- }-- };--}++const o = { name: 'Deals API', ServiceError: ApiError };--const jsonParser = express.json({ limit: '64kb' });--/** 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()));--}++/**++ * Wrap a JSON handler: its return value is the body; errors become problems. The kit's sendError does++ * not set Retry-After, so an ApiError refusal carrying err.extra.retry_after sets that header here.++ */++const run = (fn, status) => svc.run(async (req, res) => {++ try {++ return await fn(req, res);++ } catch (err) {++ if (err instanceof ApiError && err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));++ throw err;++ }++}, status, o);++++/** JSON body parser whose failures are problems too: malformed 400, over 64 kB 413, unreadable encoding 415. */++const jsonBody = svc.jsonBody({ limit: '64kb' });/** 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 (stated expiry, hotness, freshness re-indexing, Sources import).*/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: 'Deals', 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(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);-- console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.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(`[Deals] ${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(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);++ console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.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('[Deals] failed to start:', err); process.exit(1); });}--process.on('SIGTERM', () => shutdown('SIGTERM'));--process.on('SIGINT', () => shutdown('SIGINT'));--})().catch((err) => { console.error('[Deals] failed to start:', err); process.exit(1); });++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * openvibe-sdk/service (plan T1): Deals' shared JSON body parser answers 413 for a body over 64 kB++ * (the hand-rolled express.json answered 400 request.invalid_json there) and 415 for an encoding it++ * cannot read; an ApiError refusal keeps its own status and code and its Retry-After header; and the++ * entry point's graceful stop runs its stop and close steps, then exits 0.++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++const { boot, check, done } = require('./helpers/boot');++const { createLifecycle } = require('../server/index');++const errors = require('../server/http/errors');++++(async () => {++ const t = await boot();++++ const app = express();++ app.post('/echo', errors.jsonBody, errors.run(async () => ({ ok: true })));++ app.post('/refuse', errors.run(async () => { throw new errors.ApiError(429, 'rate.limited', 'Slow down', { retry_after: 7 }); }));++ 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 post = (path, body, headers = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });++++ const read = async (r) => { const text = await r.text(); return { status: r.status, headers: r.headers, text, json: JSON.parse(text) }; };++++ await check('a JSON body over 64 kB is 413 request.too_large, not 400 request.invalid_json', async () => {++ const r = await read(await post('/echo', JSON.stringify({ padding: 'y'.repeat(70 * 1024) })));++ assert.strictEqual(r.status, 413, r.text);++ assert.strictEqual(r.json.code, 'request.too_large');++ });++++ await check('malformed JSON is 400 request.invalid_json', async () => {++ const r = await read(await post('/echo', '{oops'));++ assert.strictEqual(r.status, 400, r.text);++ assert.strictEqual(r.json.code, 'request.invalid_json');++ });++++ await check('a Content-Encoding the parser cannot read is 415 request.unsupported_encoding', async () => {++ const r = await read(await post('/echo', '{}', { 'content-encoding': 'xz' }));++ assert.strictEqual(r.status, 415, r.text);++ assert.strictEqual(r.json.code, 'request.unsupported_encoding');++ });++++ await check('an ApiError refusal keeps its status and code and sets Retry-After from extra.retry_after', async () => {++ const r = await read(await post('/refuse', '{}'));++ assert.strictEqual(r.status, 429, r.text);++ assert.strictEqual(r.headers.get('retry-after'), '7');++ assert.strictEqual(r.json.code, 'rate.limited');++ });++++ 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 stopServer = http.createServer(t.app);++ await new Promise((resolve) => stopServer.listen(0, '127.0.0.1', resolve));++++ const exits = [];++ const lifecycle = createLifecycle({ server: stopServer, 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(stopServer.listening, false);++ });++++ server.close();++ try { await t.close(); } catch { /* the lifecycle above already closed the store */ }++ 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,37 +2,32 @@
@@ -2,37 +2,32 @@
/**
/**
* 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 Deals row) so every service answers the same way; the exports stay put so no call site moves.
+ * ApiError stays Deals' own refusal (server/domain/values.js): the kit answers any error carrying an
+ * HTTP status and a string code with its own status and code, and anything else as 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');
const { ApiError } = require('../domain/values');
const { ApiError } = require('../domain/values');
-/** 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;
- if (err instanceof ApiError) {
- if (err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));
- return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: err.extra || undefined });
- }
- console.error('[Deals API]', err && err.stack ? err.stack : err);
- contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
- }
- };
-}
+const o = { name: 'Deals API', ServiceError: ApiError };
-const jsonParser = express.json({ limit: '64kb' });
-/** 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()));
-}
+/**
+ * Wrap a JSON handler: its return value is the body; errors become problems. The kit's sendError does
+ * not set Retry-After, so an ApiError refusal carrying err.extra.retry_after sets that header here.
+ */
+const run = (fn, status) => svc.run(async (req, res) => {
+ try {
+ return await fn(req, res);
+ } catch (err) {
+ if (err instanceof ApiError && err.extra && err.extra.retry_after) res.set('Retry-After', String(err.extra.retry_after));
+ throw err;
+ }
+}, status, o);
+
+/** JSON body parser whose failures are problems too: malformed 400, over 64 kB 413, unreadable encoding 415. */
+const jsonBody = svc.jsonBody({ limit: '64kb' });
/** 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 (stated expiry, hotness, freshness re-indexing, Sources import).
* client secret are set) and the worker (stated expiry, hotness, freshness re-indexing, Sources import).
*/
*/
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: 'Deals', 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(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
- console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.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(`[Deals] ${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(`[Deals] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
+ console.log(`[Deals] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}; Sources import ${ctx.sources.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('[Deals] failed to start:', err); process.exit(1); });
}
}
-process.on('SIGTERM', () => shutdown('SIGTERM'));
-process.on('SIGINT', () => shutdown('SIGINT'));
-})().catch((err) => { console.error('[Deals] failed to start:', err); process.exit(1); });
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * openvibe-sdk/service (plan T1): Deals' shared JSON body parser answers 413 for a body over 64 kB
+ * (the hand-rolled express.json answered 400 request.invalid_json there) and 415 for an encoding it
+ * cannot read; an ApiError refusal keeps its own status and code and its Retry-After header; and the
+ * entry point's graceful stop runs its stop and close steps, then exits 0.
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { boot, check, done } = require('./helpers/boot');
+const { createLifecycle } = require('../server/index');
+const errors = require('../server/http/errors');
+
+(async () => {
+ const t = await boot();
+
+ const app = express();
+ app.post('/echo', errors.jsonBody, errors.run(async () => ({ ok: true })));
+ app.post('/refuse', errors.run(async () => { throw new errors.ApiError(429, 'rate.limited', 'Slow down', { retry_after: 7 }); }));
+ 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 post = (path, body, headers = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
+
+ const read = async (r) => { const text = await r.text(); return { status: r.status, headers: r.headers, text, json: JSON.parse(text) }; };
+
+ await check('a JSON body over 64 kB is 413 request.too_large, not 400 request.invalid_json', async () => {
+ const r = await read(await post('/echo', JSON.stringify({ padding: 'y'.repeat(70 * 1024) })));
+ assert.strictEqual(r.status, 413, r.text);
+ assert.strictEqual(r.json.code, 'request.too_large');
+ });
+
+ await check('malformed JSON is 400 request.invalid_json', async () => {
+ const r = await read(await post('/echo', '{oops'));
+ assert.strictEqual(r.status, 400, r.text);
+ assert.strictEqual(r.json.code, 'request.invalid_json');
+ });
+
+ await check('a Content-Encoding the parser cannot read is 415 request.unsupported_encoding', async () => {
+ const r = await read(await post('/echo', '{}', { 'content-encoding': 'xz' }));
+ assert.strictEqual(r.status, 415, r.text);
+ assert.strictEqual(r.json.code, 'request.unsupported_encoding');
+ });
+
+ await check('an ApiError refusal keeps its status and code and sets Retry-After from extra.retry_after', async () => {
+ const r = await read(await post('/refuse', '{}'));
+ assert.strictEqual(r.status, 429, r.text);
+ assert.strictEqual(r.headers.get('retry-after'), '7');
+ assert.strictEqual(r.json.code, 'rate.limited');
+ });
+
+ 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 stopServer = http.createServer(t.app);
+ await new Promise((resolve) => stopServer.listen(0, '127.0.0.1', resolve));
+
+ const exits = [];
+ const lifecycle = createLifecycle({ server: stopServer, 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(stopServer.listening, false);
+ });
+
+ server.close();
+ try { await t.close(); } catch { /* the lifecycle above already closed the store */ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });