@n42/cli 0.2.42 → 0.2.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { DOMParser, XMLSerializer } = require("xmldom");
4
+ const xpath = require("xpath");
5
+ const { NODE42_DIR, VALIDATOR_URL, EP_VALIDATE, VALIDATIONS_DIR } = require("./config");
6
+ const { startSpinner } = require("./utils");
7
+ const { fetchWithAuth } = require("./auth");
8
+ const { handleError } = require("./errors");
9
+ const C = require("./colors");
10
+
11
+ const infoAssertions = [
12
+ {
13
+ identifier: "INFO-SENDER",
14
+ flag: "INFO1",
15
+ location: "/sbdh:StandardBusinessDocumentHeader/sbdh:Sender/sbdh:Identifier",
16
+ text: "Sender participant identifier"
17
+ },
18
+ {
19
+ identifier: "INFO-RECEIVER",
20
+ flag: "INFO2",
21
+ location: "/sbdh:StandardBusinessDocumentHeader/sbdh:Receiver/sbdh:Identifier",
22
+ text: "Receiver participant identifier"
23
+ },
24
+ {
25
+ identifier: "INFO-DOC-STANDARD",
26
+ flag: "INFO1",
27
+ location: "/sbdh:StandardBusinessDocumentHeader/sbdh:DocumentIdentification/sbdh:Standard",
28
+ text: "UBL document standard"
29
+ },
30
+ {
31
+ identifier: "INFO-DOC-ID",
32
+ flag: "INFO2",
33
+ location: "/sbdh:StandardBusinessDocumentHeader/sbdh:DocumentIdentification/sbdh:InstanceIdentifier",
34
+ text: "UBL document identifier"
35
+ },
36
+ {
37
+ identifier: "INFO-DOC-CREATION-DATE",
38
+ flag: "INFO1",
39
+ location: "/sbdh:StandardBusinessDocumentHeader/sbdh:DocumentIdentification/sbdh:CreationDateAndTime",
40
+ text: "UBL document creation date"
41
+ },
42
+ {
43
+ identifier: "INFO-DOCUMENTID",
44
+ flag: "INFO0",
45
+ location: "/sbdh:StandardBusinessDocumentHeader/sbdh:BusinessScope/sbdh:Scope[sbdh:Type='DOCUMENTID']/sbdh:InstanceIdentifier",
46
+ text: "Document Identifier (Peppol Document ID)"
47
+ },
48
+ {
49
+ identifier: "INFO-PROCESSID",
50
+ flag: "INFO0",
51
+ location: "/sbdh:StandardBusinessDocumentHeader/sbdh:BusinessScope/sbdh:Scope[sbdh:Type='PROCESSID']/sbdh:InstanceIdentifier",
52
+ text: "Peppol Business Process Identifier"
53
+ },
54
+ {
55
+ identifier: "INFO-COUNTRY",
56
+ flag: "INFO0",
57
+ location: "/sbdh:StandardBusinessDocumentHeader/sbdh:BusinessScope/sbdh:Scope[sbdh:Type='COUNTRY_C1']/sbdh:InstanceIdentifier",
58
+ text: "Country code used for routing (C1)"
59
+ }
60
+ ];
61
+
62
+ function wrapXml(docName, refId, xml) {
63
+ let html;
64
+
65
+ const now = new Date();
66
+ const timeText = now.toLocaleTimeString([], {
67
+ hour: "2-digit",
68
+ minute: "2-digit"
69
+ });
70
+
71
+ const templateFile = path.join(NODE42_DIR, "assets/validator.html.template");
72
+ const template = fs.readFileSync(templateFile, "utf8");
73
+
74
+ html = template.replace("<!-- XML -->", xml);
75
+ html = html.replace("<!-- TIME -->", `${timeText} • ${docName}`);
76
+ html = html.replace("/--UUID--/", refId);
77
+
78
+ const htmlFile = path.join(VALIDATIONS_DIR, `validation.html`);
79
+ fs.writeFileSync(htmlFile, html);
80
+ return htmlFile;
81
+ }
82
+
83
+ function parseXml(xmlString) {
84
+ const parser = new DOMParser();
85
+ const doc = parser.parseFromString(xmlString, "application/xml");
86
+
87
+ const errors = doc.getElementsByTagName("parsererror");
88
+ if (errors.length > 0) {
89
+ throw new Error("Invalid XML");
90
+ }
91
+ return doc;
92
+ }
93
+
94
+ function normalizeLocation(path) {
95
+ // already normalized → do NOTHING
96
+ if (path.includes("local-name()")) {
97
+ return path;
98
+ }
99
+
100
+ const parts = path.split("/").filter(Boolean);
101
+ if (!parts.length) return "";
102
+
103
+ return parts.reduce((xp, part, i) => {
104
+ // split "Name[predicate]" into base + predicateText
105
+ const base = part.split("[")[0]; // e.g. "Scope" or "ubl:Invoice"
106
+ const baseName = base.includes(":") ? base.split(":")[1] : base;
107
+
108
+ // collect predicates: [1] and [x:Type='DOCUMENTID']
109
+ const preds = [...part.matchAll(/\[([^\]]+)\]/g)].map(m => m[1]);
110
+
111
+ let cond = `local-name()='${baseName}'`;
112
+ let pos = "";
113
+
114
+ for (const p of preds) {
115
+ // positional: [1]
116
+ if (/^\d+$/.test(p)) {
117
+ pos = `[${p}]`;
118
+ continue;
119
+ }
120
+ // typed predicate: [sbdh:Type='DOCUMENTID']
121
+ const m = p.match(/^[A-Za-z0-9_-]+:([A-Za-z0-9_-]+)\s*=\s*'([^']*)'$/);
122
+ if (m) {
123
+ const [, childName, value] = m;
124
+ cond += ` and *[local-name()='${childName}']='${value}'`;
125
+ }
126
+ }
127
+
128
+ const step = `*[${cond}]${pos}`;
129
+ return xp + (i === 0 ? `//${step}` : `/${step}`);
130
+ }, "");
131
+ }
132
+
133
+ function serializeHighlightedXml(doc) {
134
+ const serializer = new XMLSerializer();
135
+ let xml = serializer.serializeToString(doc);
136
+
137
+ xml = xml
138
+ .replace(/&/g, "&amp;")
139
+ .replace(/</g, "&lt;")
140
+ .replace(/>/g, "&gt;");
141
+
142
+ xml = xml.replace(
143
+ /(&lt;[^&\n>]*data-highlight="true"[^&\n>]*&gt;[\s\S]*?&lt;\/[^&\n>]+&gt;)/g,
144
+ m => {
145
+ const level = /data-level="(INFO0|INFO1|INFO2|ERROR|WARNING)"/.exec(m)?.[1] || "WARNING";
146
+ const msg = /data-msg="([^"]+)"/.exec(m)?.[1] || "";
147
+
148
+ // REMOVE helper attributes from output
149
+ const cleaned = m
150
+ .replace(/\sdata-highlight="true"/g, "")
151
+ .replace(/\sdata-level="[^"]*"/g, "")
152
+ .replace(/\sdata-msg="[^"]*"/g, "");
153
+
154
+ return `<span class="xml-${level.toLowerCase()}" title="${msg}">${cleaned}</span>`;
155
+ }
156
+ );
157
+
158
+ return xml;
159
+ }
160
+
161
+ function highlightByAssertions(xmlString, assertions) {
162
+ const doc = parseXml(xmlString);
163
+
164
+ assertions.forEach(a => {
165
+ const xp = normalizeLocation(a.location);
166
+ if (!xp) return;
167
+
168
+ const nodes = xpath.select(xp, doc);
169
+
170
+ for (const n of nodes) {
171
+ n.setAttribute("data-highlight", "true");
172
+ n.setAttribute("data-level", a.flag); // ERROR | WARNING
173
+ n.setAttribute("data-msg", a.text); // validator message
174
+ }
175
+ });
176
+
177
+ return serializeHighlightedXml(doc);
178
+ }
179
+
180
+ function highligtAssertions(docName, validationReport, xml) {
181
+ const assertions = [
182
+ ...infoAssertions,
183
+ ...(validationReport?.sections?.flatMap(s => s.assertions || []) ?? [])
184
+ ];
185
+ //console.log(assertions);
186
+
187
+ const refId = crypto.randomUUID();
188
+
189
+ const formattedXml = highlightByAssertions(xml, assertions);
190
+ return wrapXml(docName, refId, formattedXml)
191
+ }
192
+
193
+ function handleValidationReport(artefactFile, report) {
194
+ const seen = new Set();
195
+
196
+ const counts = (report?.sections ?? [])
197
+ .flatMap(s =>
198
+ (s.assertions ?? []).map(a => ({
199
+ ...a,
200
+ configuration: s.configuration
201
+ }))
202
+ )
203
+ .filter(a => {
204
+ const key = `${a.configuration}:${a.identifier}`;
205
+ if (seen.has(key)) return false;
206
+ seen.add(key);
207
+ return true;
208
+ })
209
+ .reduce((acc, a) => {
210
+ if (a.flag === "ERROR") acc.error++;
211
+ else if (a.flag === "WARNING") acc.warning++;
212
+ return acc;
213
+ }, { error: 0, warning: 0 });
214
+
215
+ let title = `${C.BOLD}Validation Result${C.RESET}\n\n`;
216
+ let message = "";
217
+ let color = `${C.BOLD}`;
218
+ let tip;
219
+
220
+ if (counts.error !== 0 && counts.warning !== 0) {
221
+ message += `The validator found ${counts.error} error(s) and ${counts.warning} warnings.`;
222
+ color = `${C.RED}`;
223
+ tip = `Review and correct the assertions highlighted,\nthen revalidate before sending.`
224
+ }
225
+ else if (counts.error !== 0) {
226
+ message += `The validator found ${counts.error} error(s).`;
227
+ color = `${C.RED}`;
228
+ tip = `Review and correct the assertions highlighted,\nthen revalidate before sending.`
229
+ }
230
+ else if (counts.warning !== 0) {
231
+ message += `The validator found ${counts.warning} warning(s).`;
232
+ color = `${C.YELLOW}`;
233
+ tip = `Review and correct the assertions highlighted,\nthen revalidate before sending.`
234
+ }
235
+ else {
236
+ message += "The validation completed without any assertions.";
237
+ tip = "The document has passed validation and is ready to be sent."
238
+ }
239
+
240
+ const link = `\u001B]8;;file://${artefactFile}\u0007View\u001B]8;;\u0007`;
241
+ console.log(`${title}${color}${message}${C.RESET} ${C.BLUE}[${link}]${C.RESET}\n\n${tip}\n`);
242
+ }
243
+
244
+ async function runValidation(docName, xmlDoc, options) {
245
+ const {
246
+ ruleset,
247
+ location,
248
+ runtime,
249
+ } = options;
250
+
251
+ const stopSpinner = startSpinner();
252
+
253
+ const url = new URL(`${VALIDATOR_URL}/${EP_VALIDATE}`);
254
+ url.search = new URLSearchParams({
255
+ ruleset,
256
+ ...(location && { location: "true" }),
257
+ ...(runtime && { runtime: "true" }),
258
+ }).toString();
259
+
260
+ const res = await fetchWithAuth(url.toString(), {
261
+ method: "POST",
262
+ headers: {
263
+ "Content-Type": "application/xml"
264
+ },
265
+ body: xmlDoc
266
+ });
267
+
268
+ if (!res.ok) {
269
+ const err = await res.json();
270
+ stopSpinner();
271
+
272
+ if (err.code) {
273
+ await handleError(err);
274
+ }
275
+
276
+ process.exit(1);
277
+ }
278
+
279
+ const validationReport = await res.json();
280
+ stopSpinner();
281
+
282
+ const artefactFile = highligtAssertions(docName, validationReport, xmlDoc);
283
+ handleValidationReport(artefactFile, validationReport);
284
+ }
285
+
286
+ module.exports = { runValidation };
@@ -0,0 +1,28 @@
1
+ const { execSync } = require("child_process");
2
+
3
+ function run(cmd) {
4
+ console.log(`> ${cmd}`);
5
+ return execSync(cmd, { stdio: "pipe" }).toString();
6
+ }
7
+
8
+ // detect global vs local
9
+ const CLI = process.env.N42_GLOBAL ? "n42" : "npx n42";
10
+
11
+ try {
12
+ // 1. binary exists
13
+ const version = run(`${CLI} --version`);
14
+ if (!version.trim()) throw new Error("No version output");
15
+
16
+ // 2. help works
17
+ run(`${CLI} help`);
18
+
19
+ // 3. command tree exists
20
+ run(`${CLI} validate --help`);
21
+ run(`${CLI} discover --help`);
22
+
23
+ console.log("CLI validation OK");
24
+ } catch (err) {
25
+ console.error("CLI validation FAILED");
26
+ console.error(err.message);
27
+ process.exit(1);
28
+ }
@@ -0,0 +1,83 @@
1
+ const { expect } = require("chai");
2
+ const sinon = require("sinon");
3
+
4
+ describe("CLI", () => {
5
+ let login, logout, runDiscovery;
6
+
7
+ beforeEach(() => {
8
+ sinon.restore();
9
+
10
+ // stub process.exit so tests don’t quit
11
+ sinon.stub(process, "exit").callsFake(() => {
12
+ throw new Error("process.exit");
13
+ });
14
+
15
+ // stub handlers
16
+ login = sinon.stub().resolves();
17
+ logout = sinon.stub().resolves();
18
+ runDiscovery = sinon.stub();
19
+
20
+ // stub requires BEFORE loading CLI
21
+ sinon.stub(require("../src/auth"), "login").callsFake(login);
22
+ sinon.stub(require("../src/auth"), "logout").callsFake(logout);
23
+ sinon.stub(require("../src/discover"), "runDiscovery").callsFake(runDiscovery);
24
+ });
25
+
26
+ afterEach(() => {
27
+ sinon.restore();
28
+ delete require.cache[require.resolve("../src/cli")];
29
+ });
30
+
31
+ it("runs login command", async () => {
32
+ process.argv = ["node", "n42", "login"];
33
+
34
+ require("../src/cli");
35
+
36
+ expect(login.calledOnce).to.be.true;
37
+ });
38
+
39
+ it("runs logout command", async () => {
40
+ process.argv = ["node", "n42", "logout"];
41
+
42
+ require("../src/cli");
43
+
44
+ expect(logout.calledOnce).to.be.true;
45
+ });
46
+
47
+ it("runs peppol discovery", () => {
48
+ process.argv = [
49
+ "node",
50
+ "n42",
51
+ "discover",
52
+ "peppol",
53
+ "9915:123456789",
54
+ "--env",
55
+ "TEST"
56
+ ];
57
+
58
+ require("../src/cli");
59
+
60
+ expect(runDiscovery.calledOnce).to.be.true;
61
+ expect(runDiscovery.firstCall.args[0]).to.equal("9915:123456789");
62
+ });
63
+
64
+ it("exits on invalid env", () => {
65
+ process.argv = [
66
+ "node",
67
+ "n42",
68
+ "discover",
69
+ "peppol",
70
+ "9915:123456789",
71
+ "--env",
72
+ "INVALID"
73
+ ];
74
+
75
+ try {
76
+ require("../src/cli");
77
+ } catch (e) {
78
+ // expected
79
+ }
80
+
81
+ expect(process.exit.called).to.be.true;
82
+ });
83
+ });
@@ -0,0 +1,112 @@
1
+ const { expect } = require("chai");
2
+ const sinon = require("sinon");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ describe("runDiscovery()", () => {
7
+ let runDiscovery;
8
+ let auth, user, utils, errors, db, config;
9
+
10
+ beforeEach(() => {
11
+ sinon.restore();
12
+
13
+ // ---- load modules FIRST ----
14
+ auth = require("../src/auth");
15
+ user = require("../src/user");
16
+ utils = require("../src/utils");
17
+ errors = require("../src/errors");
18
+ db = require("../src/db");
19
+ config = require("../src/config");
20
+
21
+ // ---- stub config constants ----
22
+ sinon.stub(config, "ARTEFACTS_DIR").value("/tmp/node42-artefacts");
23
+ sinon.stub(config, "NODE42_DIR").value("/tmp/node42");
24
+ sinon.stub(config, "API_URL").value("https://api.example.com");
25
+ sinon.stub(config, "EP_DISCOVER").value("discover");
26
+
27
+ // ---- fs ----
28
+ const realRead = fs.readFileSync;
29
+ sinon.stub(fs, "writeFileSync");
30
+ sinon.stub(fs, "readFileSync").callsFake((file, enc) => {
31
+ if (file.includes("wrapper.html.template")) {
32
+ return "<!-- SVG --><!-- TIME -->/--UUID--/";
33
+ }
34
+ return realRead(file, enc);
35
+ });
36
+
37
+ // ---- utils ----
38
+ sinon.stub(utils, "startSpinner").returns(() => {});
39
+ sinon.stub(utils, "getShortId").returns("abc123");
40
+ sinon.stub(utils, "getArtefactExt").returns("json");
41
+ sinon.stub(utils, "buildDocLabel");
42
+ sinon.stub(utils, "promptForDocument").resolves(null);
43
+
44
+ // ---- auth ----
45
+ sinon.stub(auth, "fetchWithAuth").resolves({
46
+ ok: true,
47
+ headers: {
48
+ get: (k) => ({
49
+ "X-Node42-RefId": "ref-123",
50
+ "X-Node42-ServiceUsage": "1",
51
+ "X-Node42-RateLimit": "100/100",
52
+ "X-Node42-Documents": null
53
+ }[k])
54
+ },
55
+ json: async () => ({ result: "ok" })
56
+ });
57
+
58
+ // ---- user ----
59
+ sinon.stub(user, "getUserWithIndex").returns({ id: "1" });
60
+ sinon.stub(user, "setUserUsage");
61
+
62
+ // ---- db ----
63
+ sinon.stub(db, "insert");
64
+
65
+ sinon.stub(console, "log");
66
+
67
+ // ---- require AFTER stubbing ----
68
+ delete require.cache[require.resolve("../src/discover")];
69
+ runDiscovery = require("../src/discover").runDiscovery;
70
+ });
71
+
72
+ afterEach(() => sinon.restore());
73
+
74
+ it("runs discovery and writes json artefact", async () => {
75
+ await runDiscovery("9915:test", {
76
+ env: "TEST",
77
+ output: "json",
78
+ format: "json"
79
+ });
80
+
81
+ expect(auth.fetchWithAuth.calledOnce).to.be.true;
82
+ expect(db.insert.calledOnce).to.be.true;
83
+ expect(fs.writeFileSync.called).to.be.true;
84
+ expect(user.setUserUsage.calledOnce).to.be.true;
85
+ });
86
+
87
+ it("handles API error response", async () => {
88
+ sinon.restore();
89
+
90
+ // stub error path BEFORE require
91
+ const auth = require("../src/auth");
92
+ const errors = require("../src/errors");
93
+
94
+ sinon.stub(auth, "fetchWithAuth").resolves({
95
+ ok: false,
96
+ json: async () => ({ code: "N42E-5000", message: "fail" })
97
+ });
98
+
99
+ sinon.stub(errors, "handleError");
100
+ sinon.stub(process, "exit").throws(new Error("exit"));
101
+
102
+ delete require.cache[require.resolve("../src/discover")];
103
+ const runDiscovery = require("../src/discover").runDiscovery;
104
+
105
+ try {
106
+ await runDiscovery("9915:test", { env: "TEST" });
107
+ } catch {}
108
+
109
+ expect(errors.handleError.calledOnce).to.be.true;
110
+ expect(process.exit.calledOnce).to.be.true;
111
+ });
112
+ });
@@ -0,0 +1,61 @@
1
+ const { expect } = require("chai");
2
+ const sinon = require("sinon");
3
+
4
+ const config = require("../src/config");
5
+ const { handleError } = require("../src/errors");
6
+
7
+ describe("handleError()", () => {
8
+ let errorStub;
9
+
10
+ beforeEach(() => {
11
+ sinon.restore();
12
+ sinon.stub(console, "error");
13
+ errorStub = sinon.stub(config, "WWW_URL").value("https://example.com");
14
+ });
15
+
16
+ afterEach(() => {
17
+ sinon.restore();
18
+ });
19
+
20
+ it("prints formatted error with code and message", () => {
21
+ handleError({
22
+ code: "N42E-5101",
23
+ message: "Rate limit exceeded"
24
+ });
25
+
26
+ expect(console.error.called).to.be.true;
27
+ expect(console.error.firstCall.args[0]).to.include("Error: 5101");
28
+ expect(console.error.firstCall.args[0]).to.include("Rate limit exceeded");
29
+ expect(console.error.firstCall.args[0]).to.include("View details");
30
+ });
31
+
32
+ it("prints error without message using documentation fallback", () => {
33
+ handleError({
34
+ code: "N42E-9032"
35
+ });
36
+
37
+ expect(console.error.called).to.be.true;
38
+ expect(console.error.firstCall.args[0]).to.include("Error: 9032");
39
+ expect(console.error.firstCall.args[0]).to.include("For details, see the documentation");
40
+ });
41
+
42
+ it("handles error without N42E prefix", () => {
43
+ handleError({
44
+ code: "UNKNOWN",
45
+ message: "Something failed"
46
+ });
47
+
48
+ expect(console.error.called).to.be.true;
49
+ expect(console.error.firstCall.args[0]).to.include("Error:");
50
+ expect(console.error.firstCall.args[0]).to.include("Something failed");
51
+ });
52
+
53
+ it("handles error without code", () => {
54
+ handleError({
55
+ message: "Generic failure"
56
+ });
57
+
58
+ expect(console.error.called).to.be.true;
59
+ expect(console.error.firstCall.args[0]).to.include("Generic failure");
60
+ });
61
+ });
@@ -0,0 +1,103 @@
1
+ const { expect } = require("chai");
2
+ const sinon = require("sinon");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+
7
+ const db = require("../src/db");
8
+
9
+ const TEST_DB = path.join(os.tmpdir(), "test-db-user.json");
10
+
11
+ describe("user", () => {
12
+ let user;
13
+
14
+ beforeEach(() => {
15
+ sinon.restore();
16
+
17
+ db.setSource(TEST_DB);
18
+ if (fs.existsSync(TEST_DB)) fs.unlinkSync(TEST_DB);
19
+
20
+ // fresh require
21
+ delete require.cache[require.resolve("../src/user")];
22
+ user = require("../src/user");
23
+ });
24
+
25
+ afterEach(() => {
26
+ sinon.restore();
27
+ if (fs.existsSync(TEST_DB)) fs.unlinkSync(TEST_DB);
28
+ });
29
+
30
+ it("returns default user when index does not exist", () => {
31
+ const u = user.getUserWithIndex(0);
32
+
33
+ expect(u.id).to.equal("n/a");
34
+ expect(u.userName).to.equal("n/a");
35
+ });
36
+
37
+ it("returns user by index when present", () => {
38
+ db.save({
39
+ user: [{
40
+ id: "1",
41
+ userName: "User",
42
+ userMail: "user@test.com",
43
+ role: "user"
44
+ }]
45
+ });
46
+
47
+ const u = user.getUserWithIndex(0);
48
+ expect(u.id).to.equal("1");
49
+ });
50
+
51
+ it("returns user by id when present", () => {
52
+ db.save({
53
+ user: [{
54
+ id: "1",
55
+ userName: "User",
56
+ userMail: "user@test.com",
57
+ role: "user"
58
+ }]
59
+ });
60
+
61
+ const u = user.getUserWithId("1");
62
+ expect(u.userMail).to.equal("user@test.com");
63
+ });
64
+
65
+ it("returns default user when id is missing", () => {
66
+ const u = user.getUserWithId("missing");
67
+ expect(u.id).to.equal("n/a");
68
+ });
69
+
70
+ it("returns undefined usage when none exists", () => {
71
+ db.save({
72
+ user: [{
73
+ id: "1",
74
+ serviceUsage: {}
75
+ }]
76
+ });
77
+
78
+ const usage = user.getUserUsage("1", "discovery", "2026-02");
79
+ expect(usage).to.be.undefined;
80
+ });
81
+
82
+ it("sets and retrieves service usage", () => {
83
+ db.save({
84
+ user: [{
85
+ id: "1",
86
+ serviceUsage: {}
87
+ }]
88
+ });
89
+
90
+ user.setUserUsage("1", "discovery", "2026-02", 5);
91
+
92
+ const usage = user.getUserUsage("1", "discovery", "2026-02");
93
+ expect(usage).to.equal(5);
94
+ });
95
+
96
+ it("does nothing when setting usage for missing user", () => {
97
+ const spy = sinon.spy(db, "save");
98
+
99
+ user.setUserUsage("missing", "discovery", "2026-02", 5);
100
+
101
+ expect(spy.called).to.be.false;
102
+ });
103
+ });