openvibers/openvibe.tips Commits
Move Tips errors and shutdown onto openvibe-sdk/service (#3)
Changes
@@@ -15,7 +15,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",++ "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"},@@@ -1133,9 +1133,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.25.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",-- "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",++ "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"@@@ -27,7 +27,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",++ "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"},@@@ -39,8 +39,8 @@* but the creator and granted services.*/const express = require('express');--const { http } = require('openvibe-contracts');--const { TipsError, fail, userSubject, displayName, inputError } = require('../util');++const svc = require('openvibe-sdk/service');++const { fail, userSubject, displayName, inputError } = require('../util');const { idempotent } = require('./idempotency');const CAP = {@@@ -401,24 +401,17 @@ function v1Router({ domain, apiAuth, limits }) {return r;}--/** Async-safe handler: TipsError → problem+json; anything else → 500. */--function wrap(fn) {-- return async (req, res, next) => {-- try {-- const p = await fn(req, res, next);-- if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));-- } catch (e) { sendError(req, res, e); }-- };--}++/**++ * Async-safe handler: TipsError → problem+json; anything else → 500 tips.internal with 'internal error'.++ * Built from openvibe-sdk/service (plan T1) with Tips' own options — the fallback code and detail, and++ * `extra` nested as { details } below 500, are the kit's internalCode / internalDetail / extra: 'details';++ * `map: inputError` keeps the database-refusal mapping (a value PostgreSQL cannot store → 422++ * tips.invalid_input). The exported signatures stay (fn) / (req, res, e), so no call site moves.++ */++const TIPS_ERRORS = { name: 'Tips', extra: 'details', internalCode: 'tips.internal', internalDetail: 'internal error', map: inputError };--function sendError(req, res, e) {-- if (res.headersSent) return;-- e = inputError(e) || e;-- if (e instanceof TipsError) {-- return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });-- }-- console.error('[Tips] unexpected error:', e);-- return http.sendProblem(res, 500, 'tips.internal', { detail: 'internal error', ctx: req.ov });--}++const wrap = (fn) => svc.wrap(fn, TIPS_ERRORS);++++const sendError = (req, res, e) => svc.sendError(res, req, e, undefined, TIPS_ERRORS);module.exports = { v1Router, wrap, sendError, CAP };@@@ -21,8 +21,29 @@ const { openDb, migrate } = require('./db');const { createApp } = require('./app');const { createValkey } = require('openvibe-sdk/valkey');const { createRegistry } = require('openvibe-shared/metrics');++const { gracefulStop } = require('openvibe-sdk/service');--async function main() {++/**++ * The process stop (openvibe-sdk/service, plan T1): the job timers and the relays stop taking new++ * work (they ran before the old server.close), the HTTP drain runs (defaults 4000/5000), then the++ * database and Valkey close; 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, app, timers = [], exit, signals } = {}) {++ const { db, valkey, domain, keys, outbox } = app.locals;++ return gracefulStop({++ name: 'Tips', server, deadlineExitCode: 0, exit, signals,++ stop: [++ () => timers.forEach(clearInterval),++ () => keys.stop(),++ () => domain.overlays.close(),++ () => outbox.stop(),++ ],++ close: [() => db.close(), () => { if (valkey) return valkey.close(); }],++ });++}++++async function start() {const config = loadConfig();const registry = createRegistry();const db = openDb(config, { registry });@@@ -51,26 +72,15 @@ async function main() {});server.keepAliveTimeout = 65_000;-- let closing = false;-- async function shutdown(signal) {-- if (closing) return;-- closing = true;-- console.log(`[Tips] ${signal} — closing`);-- setTimeout(() => process.exit(0), 5000).unref();-- timers.forEach(clearInterval);-- keys.stop();-- await domain.overlays.close().catch(() => {});-- await outbox.stop();-- await new Promise((resolve) => server.close(resolve));-- await db.close().catch(() => {});-- if (valkey) await valkey.close().catch(() => {});-- process.exit(0);-- }-- process.on('SIGTERM', async () => { await shutdown('SIGTERM'); });-- process.on('SIGINT', async () => { await shutdown('SIGINT'); });++ createLifecycle({ server, app, timers });++ return { server, app };++}++++if (require.main === module) {++ start().catch((e) => {++ console.error(`[Tips] could not start: ${e.message}`);++ process.exit(1);++ });}--main().catch((e) => {-- console.error(`[Tips] could not start: ${e.message}`);-- process.exit(1);--});++module.exports = { start, createLifecycle };@@@ -3,16 +3,12 @@/** Small shared pieces: ids, time, the error type, input checks. */const crypto = require('crypto');const { ids, validate } = require('openvibe-contracts');++const { createServiceError } = require('openvibe-sdk/service');--class TipsError extends Error {-- constructor(status, code, detail, extra) {-- super(detail || code);-- this.status = status;-- this.code = code;-- this.detail = detail;-- this.extra = extra;-- }--}++// openvibe-sdk/service (plan T1): the kit's error class with Tips' name. Same constructor++// (status, code, detail, extra) and same fields, so `instanceof TipsError`, fail() and every++// call site stay exactly as they were.++const TipsError = createServiceError('TipsError');function fail(status, code, detail, extra) { throw new TipsError(status, code, detail, extra); }nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++/**++ * openvibe-sdk/service (plan T1): Tips' error half is the kit's wrap/sendError with Tips' options, so a++ * TipsError answers its own status/code/detail with `extra` nested as { details } below 500, and anything++ * else answers 500 tips.internal with 'internal error'. The kit's JSON parser (wired the way a service++ * adopts it) answers 413 request.too_large / 400 request.invalid_json / 415 request.unsupported_encoding;++ * Tips' own app keeps express.json, so its current codes are pinned unchanged (413 request.too_large,++ * 400 request.malformed_json). The entry point's graceful stop runs its stop and close steps in order,++ * then exits 0.++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++const svc = require('openvibe-sdk/service');++const { boot, check, done } = require('./helpers/app');++const { wrap } = require('../server/api/v1');++const { TipsError } = require('../server/util');++++(async () => {++ const t = await boot();++++ // A service-shaped app on the kit's parser + Tips' wrap, the way the recipe wires it.++ const app = express();++ app.post('/echo', svc.jsonBody({ limit: '64kb' }), wrap(async (req, res) => { res.json({ ok: true }); }));++ app.post('/refuse', wrap(async () => { throw new TipsError(422, 'tips.test_refusal', 'that is not allowed', { field: 'creator' }); }));++ app.post('/gone', wrap(async () => { throw new TipsError(503, 'tips.billing_unavailable', 'Billing is not answering right now; nothing was charged'); }));++ app.post('/boom', wrap(async () => { throw new Error('boom'); }));++ 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 = (p, body, headers = {}) => fetch(base + p, { 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('the kit parser: a JSON body over 64 kB is 413 request.too_large', 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('the kit parser: 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('the kit parser: an unreadable Content-Encoding 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('Tips\' app keeps its own parser answers: over 64 kB 413 request.too_large, malformed 400 request.malformed_json', async () => {++ const big = await read(await fetch(`${t.base}/api/v1/checkout`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ padding: 'y'.repeat(70 * 1024) }) }));++ assert.strictEqual(big.status, 413, big.text);++ assert.strictEqual(big.json.code, 'request.too_large');++ const bad = await read(await fetch(`${t.base}/api/v1/checkout`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }));++ assert.strictEqual(bad.status, 400, bad.text);++ assert.strictEqual(bad.json.code, 'request.malformed_json');++ });++++ await check('a TipsError answers its own status/code/detail and nests extra as { details } below 500', async () => {++ const r = await read(await post('/refuse', '{}'));++ assert.strictEqual(r.status, 422, r.text);++ assert.strictEqual(r.json.code, 'tips.test_refusal');++ assert.strictEqual(r.json.detail, 'that is not allowed');++ assert.deepStrictEqual(r.json.details, { field: 'creator' });++ assert.strictEqual(r.json.error, 'that is not allowed');++ });++++ await check('a 503 TipsError keeps its detail (the fallback detail is only for an unexpected 500)', async () => {++ const r = await read(await post('/gone', '{}'));++ assert.strictEqual(r.status, 503, r.text);++ assert.strictEqual(r.json.code, 'tips.billing_unavailable');++ assert.strictEqual(r.json.detail, 'Billing is not answering right now; nothing was charged');++ assert.strictEqual(r.json.details, undefined);++ });++++ await check('an unexpected throw answers 500 tips.internal with \'internal error\'', async () => {++ const r = await read(await post('/boom', '{}'));++ assert.strictEqual(r.status, 500, r.text);++ assert.strictEqual(r.json.code, 'tips.internal');++ assert.strictEqual(r.json.detail, 'internal error');++ });++++ await check('the entry point stop runs its stop and close steps in order, 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.app.locals.keys, 'stop', 'keys.stop');++ spy(t.app.locals.domain.overlays, 'close', 'overlays.close');++ spy(t.app.locals.outbox, 'stop', 'outbox.stop');++ spy(t.app.locals.db, 'close', 'db.close');++ const closesValkey = !!(t.app.locals.valkey && typeof t.app.locals.valkey.close === 'function');++ if (closesValkey) spy(t.app.locals.valkey, 'close', 'valkey.close');++++ const stopServer = http.createServer(t.app);++ await new Promise((resolve) => stopServer.listen(0, '127.0.0.1', resolve));++++ const { createLifecycle } = require('../server/index');++ const exits = [];++ const lifecycle = createLifecycle({ server: stopServer, app: t.app, timers: [], exit: (code) => exits.push(code), signals: false });++ const code = await lifecycle.stop('SIGTERM');++++ const expected = ['keys.stop', 'overlays.close', 'outbox.stop', 'db.close'];++ if (closesValkey) expected.push('valkey.close');++ assert.strictEqual(code, 0);++ assert.deepStrictEqual(exits, [0]);++ assert.deepStrictEqual(steps, expected);++ assert.strictEqual(stopServer.listening, false);++ });++++ server.close();++ try { await t.close(); } catch { /* the lifecycle above already closed the db and valkey */ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
+ "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"
},
},
@@ -1133,9 +1133,9 @@
@@ -1133,9 +1133,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.25.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
- "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",
+ "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"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
+ "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"
},
},
@@ -39,8 +39,8 @@
@@ -39,8 +39,8 @@
* but the creator and granted services.
* but the creator and granted services.
*/
*/
const express = require('express');
const express = require('express');
-const { http } = require('openvibe-contracts');
-const { TipsError, fail, userSubject, displayName, inputError } = require('../util');
+const svc = require('openvibe-sdk/service');
+const { fail, userSubject, displayName, inputError } = require('../util');
const { idempotent } = require('./idempotency');
const { idempotent } = require('./idempotency');
const CAP = {
const CAP = {
@@ -401,24 +401,17 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -401,24 +401,17 @@ function v1Router({ domain, apiAuth, limits }) {
return r;
return r;
}
}
-/** Async-safe handler: TipsError → problem+json; anything else → 500. */
-function wrap(fn) {
- return async (req, res, next) => {
- try {
- const p = await fn(req, res, next);
- if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
- } catch (e) { sendError(req, res, e); }
- };
-}
+/**
+ * Async-safe handler: TipsError → problem+json; anything else → 500 tips.internal with 'internal error'.
+ * Built from openvibe-sdk/service (plan T1) with Tips' own options — the fallback code and detail, and
+ * `extra` nested as { details } below 500, are the kit's internalCode / internalDetail / extra: 'details';
+ * `map: inputError` keeps the database-refusal mapping (a value PostgreSQL cannot store → 422
+ * tips.invalid_input). The exported signatures stay (fn) / (req, res, e), so no call site moves.
+ */
+const TIPS_ERRORS = { name: 'Tips', extra: 'details', internalCode: 'tips.internal', internalDetail: 'internal error', map: inputError };
-function sendError(req, res, e) {
- if (res.headersSent) return;
- e = inputError(e) || e;
- if (e instanceof TipsError) {
- return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });
- }
- console.error('[Tips] unexpected error:', e);
- return http.sendProblem(res, 500, 'tips.internal', { detail: 'internal error', ctx: req.ov });
-}
+const wrap = (fn) => svc.wrap(fn, TIPS_ERRORS);
+
+const sendError = (req, res, e) => svc.sendError(res, req, e, undefined, TIPS_ERRORS);
module.exports = { v1Router, wrap, sendError, CAP };
module.exports = { v1Router, wrap, sendError, CAP };
@@ -21,8 +21,29 @@ const { openDb, migrate } = require('./db');
@@ -21,8 +21,29 @@ const { openDb, migrate } = require('./db');
const { createApp } = require('./app');
const { createApp } = require('./app');
const { createValkey } = require('openvibe-sdk/valkey');
const { createValkey } = require('openvibe-sdk/valkey');
const { createRegistry } = require('openvibe-shared/metrics');
const { createRegistry } = require('openvibe-shared/metrics');
+const { gracefulStop } = require('openvibe-sdk/service');
-async function main() {
+/**
+ * The process stop (openvibe-sdk/service, plan T1): the job timers and the relays stop taking new
+ * work (they ran before the old server.close), the HTTP drain runs (defaults 4000/5000), then the
+ * database and Valkey close; 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, app, timers = [], exit, signals } = {}) {
+ const { db, valkey, domain, keys, outbox } = app.locals;
+ return gracefulStop({
+ name: 'Tips', server, deadlineExitCode: 0, exit, signals,
+ stop: [
+ () => timers.forEach(clearInterval),
+ () => keys.stop(),
+ () => domain.overlays.close(),
+ () => outbox.stop(),
+ ],
+ close: [() => db.close(), () => { if (valkey) return valkey.close(); }],
+ });
+}
+
+async function start() {
const config = loadConfig();
const config = loadConfig();
const registry = createRegistry();
const registry = createRegistry();
const db = openDb(config, { registry });
const db = openDb(config, { registry });
@@ -51,26 +72,15 @@ async function main() {
@@ -51,26 +72,15 @@ async function main() {
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
- let closing = false;
- async function shutdown(signal) {
- if (closing) return;
- closing = true;
- console.log(`[Tips] ${signal} — closing`);
- setTimeout(() => process.exit(0), 5000).unref();
- timers.forEach(clearInterval);
- keys.stop();
- await domain.overlays.close().catch(() => {});
- await outbox.stop();
- await new Promise((resolve) => server.close(resolve));
- await db.close().catch(() => {});
- if (valkey) await valkey.close().catch(() => {});
- process.exit(0);
- }
- process.on('SIGTERM', async () => { await shutdown('SIGTERM'); });
- process.on('SIGINT', async () => { await shutdown('SIGINT'); });
+ createLifecycle({ server, app, timers });
+ return { server, app };
+}
+
+if (require.main === module) {
+ start().catch((e) => {
+ console.error(`[Tips] could not start: ${e.message}`);
+ process.exit(1);
+ });
}
}
-main().catch((e) => {
- console.error(`[Tips] could not start: ${e.message}`);
- process.exit(1);
-});
+module.exports = { start, createLifecycle };
@@ -3,16 +3,12 @@
@@ -3,16 +3,12 @@
/** Small shared pieces: ids, time, the error type, input checks. */
/** Small shared pieces: ids, time, the error type, input checks. */
const crypto = require('crypto');
const crypto = require('crypto');
const { ids, validate } = require('openvibe-contracts');
const { ids, validate } = require('openvibe-contracts');
+const { createServiceError } = require('openvibe-sdk/service');
-class TipsError extends Error {
- constructor(status, code, detail, extra) {
- super(detail || code);
- this.status = status;
- this.code = code;
- this.detail = detail;
- this.extra = extra;
- }
-}
+// openvibe-sdk/service (plan T1): the kit's error class with Tips' name. Same constructor
+// (status, code, detail, extra) and same fields, so `instanceof TipsError`, fail() and every
+// call site stay exactly as they were.
+const TipsError = createServiceError('TipsError');
function fail(status, code, detail, extra) { throw new TipsError(status, code, detail, extra); }
function fail(status, code, detail, extra) { throw new TipsError(status, code, detail, extra); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+/**
+ * openvibe-sdk/service (plan T1): Tips' error half is the kit's wrap/sendError with Tips' options, so a
+ * TipsError answers its own status/code/detail with `extra` nested as { details } below 500, and anything
+ * else answers 500 tips.internal with 'internal error'. The kit's JSON parser (wired the way a service
+ * adopts it) answers 413 request.too_large / 400 request.invalid_json / 415 request.unsupported_encoding;
+ * Tips' own app keeps express.json, so its current codes are pinned unchanged (413 request.too_large,
+ * 400 request.malformed_json). The entry point's graceful stop runs its stop and close steps in order,
+ * then exits 0.
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const svc = require('openvibe-sdk/service');
+const { boot, check, done } = require('./helpers/app');
+const { wrap } = require('../server/api/v1');
+const { TipsError } = require('../server/util');
+
+(async () => {
+ const t = await boot();
+
+ // A service-shaped app on the kit's parser + Tips' wrap, the way the recipe wires it.
+ const app = express();
+ app.post('/echo', svc.jsonBody({ limit: '64kb' }), wrap(async (req, res) => { res.json({ ok: true }); }));
+ app.post('/refuse', wrap(async () => { throw new TipsError(422, 'tips.test_refusal', 'that is not allowed', { field: 'creator' }); }));
+ app.post('/gone', wrap(async () => { throw new TipsError(503, 'tips.billing_unavailable', 'Billing is not answering right now; nothing was charged'); }));
+ app.post('/boom', wrap(async () => { throw new Error('boom'); }));
+ 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 = (p, body, headers = {}) => fetch(base + p, { 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('the kit parser: a JSON body over 64 kB is 413 request.too_large', 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('the kit parser: 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('the kit parser: an unreadable Content-Encoding 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('Tips\' app keeps its own parser answers: over 64 kB 413 request.too_large, malformed 400 request.malformed_json', async () => {
+ const big = await read(await fetch(`${t.base}/api/v1/checkout`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ padding: 'y'.repeat(70 * 1024) }) }));
+ assert.strictEqual(big.status, 413, big.text);
+ assert.strictEqual(big.json.code, 'request.too_large');
+ const bad = await read(await fetch(`${t.base}/api/v1/checkout`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }));
+ assert.strictEqual(bad.status, 400, bad.text);
+ assert.strictEqual(bad.json.code, 'request.malformed_json');
+ });
+
+ await check('a TipsError answers its own status/code/detail and nests extra as { details } below 500', async () => {
+ const r = await read(await post('/refuse', '{}'));
+ assert.strictEqual(r.status, 422, r.text);
+ assert.strictEqual(r.json.code, 'tips.test_refusal');
+ assert.strictEqual(r.json.detail, 'that is not allowed');
+ assert.deepStrictEqual(r.json.details, { field: 'creator' });
+ assert.strictEqual(r.json.error, 'that is not allowed');
+ });
+
+ await check('a 503 TipsError keeps its detail (the fallback detail is only for an unexpected 500)', async () => {
+ const r = await read(await post('/gone', '{}'));
+ assert.strictEqual(r.status, 503, r.text);
+ assert.strictEqual(r.json.code, 'tips.billing_unavailable');
+ assert.strictEqual(r.json.detail, 'Billing is not answering right now; nothing was charged');
+ assert.strictEqual(r.json.details, undefined);
+ });
+
+ await check('an unexpected throw answers 500 tips.internal with \'internal error\'', async () => {
+ const r = await read(await post('/boom', '{}'));
+ assert.strictEqual(r.status, 500, r.text);
+ assert.strictEqual(r.json.code, 'tips.internal');
+ assert.strictEqual(r.json.detail, 'internal error');
+ });
+
+ await check('the entry point stop runs its stop and close steps in order, 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.app.locals.keys, 'stop', 'keys.stop');
+ spy(t.app.locals.domain.overlays, 'close', 'overlays.close');
+ spy(t.app.locals.outbox, 'stop', 'outbox.stop');
+ spy(t.app.locals.db, 'close', 'db.close');
+ const closesValkey = !!(t.app.locals.valkey && typeof t.app.locals.valkey.close === 'function');
+ if (closesValkey) spy(t.app.locals.valkey, 'close', 'valkey.close');
+
+ const stopServer = http.createServer(t.app);
+ await new Promise((resolve) => stopServer.listen(0, '127.0.0.1', resolve));
+
+ const { createLifecycle } = require('../server/index');
+ const exits = [];
+ const lifecycle = createLifecycle({ server: stopServer, app: t.app, timers: [], exit: (code) => exits.push(code), signals: false });
+ const code = await lifecycle.stop('SIGTERM');
+
+ const expected = ['keys.stop', 'overlays.close', 'outbox.stop', 'db.close'];
+ if (closesValkey) expected.push('valkey.close');
+ assert.strictEqual(code, 0);
+ assert.deepStrictEqual(exits, [0]);
+ assert.deepStrictEqual(steps, expected);
+ assert.strictEqual(stopServer.listening, false);
+ });
+
+ server.close();
+ try { await t.close(); } catch { /* the lifecycle above already closed the db and valkey */ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });