@n42/cli 0.2.32 → 0.2.71
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.
- package/.github/workflows/ci.yaml +36 -0
- package/.github/workflows/cli-test-npm.yaml +66 -0
- package/README.md +30 -10
- package/package.json +28 -5
- package/src/assets/{wrapper.html.template → discover.html.template} +1 -1
- package/src/assets/validator-light.css +49 -0
- package/src/assets/validator.html.template +27 -0
- package/src/assets/wrapper-light.css +7 -0
- package/src/auth.js +60 -5
- package/src/cli.js +71 -13
- package/src/completion/bash.sh +7 -1
- package/src/config.js +4 -1
- package/src/discover.js +1 -1
- package/src/errors.js +4 -2
- package/src/utils.js +6 -1
- package/src/validator.js +286 -0
- package/test/asserts/validate_tests.js +28 -0
- package/test/cli.test.js +83 -0
- package/test/discover.test.js +112 -0
- package/test/errors.test.js +61 -0
- package/test/user.test.js +103 -0
- package/test/utils.test.js +105 -7
- package/test/validator.test.js +66 -0
- package/jest.config.js +0 -6
- package/src/browser.js +0 -11
- /package/src/assets/{wrapper.js → discover.js} +0 -0
package/src/validator.js
ADDED
|
@@ -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, "&")
|
|
139
|
+
.replace(/</g, "<")
|
|
140
|
+
.replace(/>/g, ">");
|
|
141
|
+
|
|
142
|
+
xml = xml.replace(
|
|
143
|
+
/(<[^&\n>]*data-highlight="true"[^&\n>]*>[\s\S]*?<\/[^&\n>]+>)/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
|
+
}
|
package/test/cli.test.js
ADDED
|
@@ -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
|
+
});
|