diff --git a/.test-temp/invoice-dc339784-d18d-4779-bfad-2eaa2b08158a.xml b/.test-temp/invoice-dc339784-d18d-4779-bfad-2eaa2b08158a.xml new file mode 100644 index 0000000..bab2249 --- /dev/null +++ b/.test-temp/invoice-dc339784-d18d-4779-bfad-2eaa2b08158a.xml @@ -0,0 +1,78 @@ + + + + + urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic + + + + 1234567890 + 380 + + 20250713 + + + + + + 1 + + + Item 1 + + + + 100.00 + + + + 1 + + + + VAT + S + 19.00 + + + 100.00 + + + + + + 7.00 + + false + + 95 + Discount + + VAT + S + + + + 3.00 + + false + + 95 + Coupon + + VAT + S + + + + 100.00 + 10.00 + 90.00 + 17.10 + 107.10 + 5.00 + 102.10 + + + + diff --git a/package.json b/package.json index 4e62788..e4b6b89 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,11 @@ "cross-env": "^7.0.3", "tinyglobby": "^0.2.11", "turbo": "^2.4.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^3.0.4", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "@types/xml2js": "^0.4.14" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9b759a..1947aef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,16 @@ importers: typescript: specifier: ^5.7.3 version: 5.7.3 + vitest: + specifier: ^3.0.4 + version: 3.0.6(@types/debug@4.1.12)(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2) + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + devDependencies: + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 dev/bun: dependencies: @@ -47,13 +57,13 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.2.17 + version: 1.2.18(@types/react@19.0.10) '@types/react': specifier: ^19.0.9 version: 19.0.10 bun: specifier: latest - version: 1.2.17 + version: 1.2.18 dev/node: dependencies: @@ -135,7 +145,7 @@ importers: version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) node-zugferd: specifier: latest - version: 0.0.10-beta.1 + version: 0.0.10-beta.3 prism-react-renderer: specifier: ^2.4.1 version: 2.4.1(react@19.0.0) @@ -1134,58 +1144,58 @@ packages: resolution: {integrity: sha512-UXQYvN0DYl5EMOXX3O0Rwke+0R0Pd7PW/hOVwgpPd6KKJPb3RP74m3PEbEFjdTzZVLUW81o7herYXD2h4PVcGQ==} engines: {node: '>= 20.0.0'} - '@oven/bun-darwin-aarch64@1.2.17': - resolution: {integrity: sha512-66Xjz3NZXUUWKZJPvWKuwEkaqMZpir1Gm4SbhbB2iiRSSTW8jqwdkSb9RhgTCDt5OnSPd3+Cq0WsP/T5ExJbhA==} + '@oven/bun-darwin-aarch64@1.2.18': + resolution: {integrity: sha512-GNxVh9VUOQ6S0aDp4Qe80MGadGbh8BS6p3jEHXIboRoTrb/80oR0csMjGUpdwGa2hX1zTvpPBwOFXvVP9UaB0Q==} cpu: [arm64] os: [darwin] - '@oven/bun-darwin-x64-baseline@1.2.17': - resolution: {integrity: sha512-VSIctl90tV8yg1LRMvPg/8LgUzl55Q7Jcxe+u6PfuvLQIJOTIPbNn7HtRpJg7MGc3+qyztB5KDd70xC7qI2yEg==} + '@oven/bun-darwin-x64-baseline@1.2.18': + resolution: {integrity: sha512-LT/MF4DySLjskZf4mUgVXhpDBCuGXI7+uHJTiAjinddglh7ENbrSRuM01cjlJ/dxivvekq5+w6k9gdYpHUibuw==} cpu: [x64] os: [darwin] - '@oven/bun-darwin-x64@1.2.17': - resolution: {integrity: sha512-OMJMHpcpBlWcVnWfSQ6x+8fF7HpkQLqBfoIvzxgUjIZZvj2d8K46XX4N/h62RglDEinRC9VDGxt24vwvlk5tTw==} + '@oven/bun-darwin-x64@1.2.18': + resolution: {integrity: sha512-/oxsG7eIkvw3rxt3V9gqY23i0ajk8m1cG/FedRj8b15GW2TgA+F9F6FQNLqxc/59SBkcrbTLoqk5EtAQwuwi/w==} cpu: [x64] os: [darwin] - '@oven/bun-linux-aarch64-musl@1.2.17': - resolution: {integrity: sha512-PH+hUV+I6DGD1VRHdAIAKEAOed+GSdvn6S1b3qqX27/VuHBU781V+hzt+6DBlcWBHYLw8PIg9sfIdNp485gQmw==} + '@oven/bun-linux-aarch64-musl@1.2.18': + resolution: {integrity: sha512-hk58uY6LSvDn2WDB8o/WAVCOZERYZPShUujI8rCwcDXkQRI4pbm5B5RJP5wEF0fClRI+WXxyyoBFsTKb7lbgyQ==} cpu: [aarch64] os: [linux] - '@oven/bun-linux-aarch64@1.2.17': - resolution: {integrity: sha512-KPoMqaibCXcSv+VZ3uMqKUNZqMxE6Hho1be6+laolYGOIJxJTMnZPfmKfIlQmnnW3vLlm3g2Rm8pPPC7doSHWg==} + '@oven/bun-linux-aarch64@1.2.18': + resolution: {integrity: sha512-0uTiUZJFS69LbYPCw963BAdP4wvUXEozbNf7vrB/3rT82x+fPZKF3C+4nfFScm+6UYusjH468vG7/g9x38jBIg==} cpu: [arm64] os: [linux] - '@oven/bun-linux-x64-baseline@1.2.17': - resolution: {integrity: sha512-IrnFMUwYWxoKICQgK8ZlJ6rI/HU2gITFNEW0MIOPIcuT0s3j0/33631M9EzYDoL4NuLQPks6569JDvSHEVqdeA==} + '@oven/bun-linux-x64-baseline@1.2.18': + resolution: {integrity: sha512-ERnR7gZz/YYpo/ZhRKXvY9qtsJNQnTrp5HayExfvD1achoHcYEvf3TarajRLVC7gDi7BxlaOPZyJjgdo5g0tUg==} cpu: [x64] os: [linux] - '@oven/bun-linux-x64-musl-baseline@1.2.17': - resolution: {integrity: sha512-YE5wQ/YA79BykMLhuwgdoF8Yjj5dRipD8dwmXs8n7gzR+/L9tL7Q69NQgskW2KkAalmWPoGAv3TV0IwbU+1dFw==} + '@oven/bun-linux-x64-musl-baseline@1.2.18': + resolution: {integrity: sha512-u4sqExX5gdcMRdwzL16qP/xJlnxVR+fF43GGQJNopOTXDrsK33BXw3aUObHRtVkqRiK3cyubJUgTtz2ykQ4Dng==} cpu: [x64] os: [linux] - '@oven/bun-linux-x64-musl@1.2.17': - resolution: {integrity: sha512-fW9qn/WqO131/qSIkIPW8zN+thQnYUWa/k98EWubLG87htKSPh1v023E5ikKb7WlUv4Yb6UlE/z4NmMYKffmAg==} + '@oven/bun-linux-x64-musl@1.2.18': + resolution: {integrity: sha512-Oqj8yDkObDWMlxzbhOefb+B75tgKEP4uGEFcBHXjVxSEL0lB7B7LYTvTpeDm8QPldhLs1xAN4FtzZlPUn6qI+Q==} cpu: [x64] os: [linux] - '@oven/bun-linux-x64@1.2.17': - resolution: {integrity: sha512-BfySnrTxp7D9hVUi9JEpviJl8ndsuESiRiQKTzgmdTLrMjUxP4SwrwMtYt6R9X20n9rREG6a47C0IyQMhbwG/g==} + '@oven/bun-linux-x64@1.2.18': + resolution: {integrity: sha512-okHdy9+Yov5BvI19FynnvsmQUP477SNJRv33TIHxs9cpj/ClgaYXMihS+yH0LCzYDFIeojfABiIHdBVUFmxqtQ==} cpu: [x64] os: [linux] - '@oven/bun-windows-x64-baseline@1.2.17': - resolution: {integrity: sha512-aVkq4l1yZ9VKfBOtZ2HEj0OCU5kUe3Fx6LbAG6oY6OglWVYj051i3RGaE2OdR4L4F2jDyxzfGYRTM/qs8nU5qA==} + '@oven/bun-windows-x64-baseline@1.2.18': + resolution: {integrity: sha512-n5XF3N0Kr53z4NnVWfTqS72U2rSHJlFafO70SOSzgiu26ylKTGOC9BBsvEQhKld4nKAsbp8YjpOViomrtC6bCQ==} cpu: [x64] os: [win32] - '@oven/bun-windows-x64@1.2.17': - resolution: {integrity: sha512-GJUg1oA59DWH6eyV8uccpgfTEVxjmgfTWQCOl2ySMXR3IfRoFwS4aQfpjcVzNmEZrv8eYt+yMuw1K7aNcWTTIg==} + '@oven/bun-windows-x64@1.2.18': + resolution: {integrity: sha512-jklsKWT9zfh8wXewKPfO7Uq8vo72esaQoGzCTTt0NKY+juXvyKaiMHEfT7v4o7cmrql3QPeVtsbp9uNAiuotgw==} cpu: [x64] os: [win32] @@ -1939,8 +1949,8 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} - '@types/bun@1.2.17': - resolution: {integrity: sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q==} + '@types/bun@1.2.18': + resolution: {integrity: sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ==} '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2013,6 +2023,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -2235,11 +2248,13 @@ packages: engines: {node: '>=18'} hasBin: true - bun-types@1.2.17: - resolution: {integrity: sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ==} + bun-types@1.2.18: + resolution: {integrity: sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw==} + peerDependencies: + '@types/react': ^19 - bun@1.2.17: - resolution: {integrity: sha512-lrUZTWS24eVy6v+Eph8VTwqFPcG7/XQ0rLBQEMNoQs2Vd7ctVdMGAzJKKGZRUQH+rgkD8rBeHGIVoWxX4vJLCA==} + bun@1.2.18: + resolution: {integrity: sha512-OR+EpNckoJN4tHMVZPaTPxDj2RgpJgJwLruTIFYbO3bQMguLd0YrmkWKYqsiihcLgm2ehIjF/H1RLfZiRa7+qQ==} cpu: [arm64, x64, aarch64] os: [darwin, linux, win32] hasBin: true @@ -3517,8 +3532,8 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} - node-zugferd@0.0.10-beta.1: - resolution: {integrity: sha512-4DBucg/RfyEzSHfcDdb0QSeZ+qKGTv87Cs33BAaQyRuI9Z0525Jx4466SJWJv3wXlL74MWJF8Ssnw7Yo5rd0oA==} + node-zugferd@0.0.10-beta.3: + resolution: {integrity: sha512-6tq8ApQ7A+a8XCH95dVlC7DWQ28uk7KdRyDmmcn/yNEH76AFEH4KhyxRtvzpx1GGIEvJs2J1aL1T85gDhfZB5w==} npm-to-yarn@3.0.1: resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} @@ -3902,6 +3917,9 @@ packages: resolution: {integrity: sha512-0SbjchvDrDbeXeQgxWVtSWxww7qcFgk3DtSE2/blHOSlLsSHwIqO2fCrtVa/EudJ7Eqno8A33QNx56rUyGbLuw==} engines: {node: '>=16'} + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -4484,6 +4502,14 @@ packages: utf-8-validate: optional: true + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xsd-schema-validator@0.10.0: resolution: {integrity: sha512-G1GtYp9Smww5D9U3QJy/uMeoaDlEYg5BR4qZYSBZWa/5TG5az2j3Np27uLKaRcg6ajwe3Ew6SJrAo3B/QFrgdg==} engines: {node: '>= 18'} @@ -5122,37 +5148,37 @@ snapshots: '@orama/orama@3.1.9': {} - '@oven/bun-darwin-aarch64@1.2.17': + '@oven/bun-darwin-aarch64@1.2.18': optional: true - '@oven/bun-darwin-x64-baseline@1.2.17': + '@oven/bun-darwin-x64-baseline@1.2.18': optional: true - '@oven/bun-darwin-x64@1.2.17': + '@oven/bun-darwin-x64@1.2.18': optional: true - '@oven/bun-linux-aarch64-musl@1.2.17': + '@oven/bun-linux-aarch64-musl@1.2.18': optional: true - '@oven/bun-linux-aarch64@1.2.17': + '@oven/bun-linux-aarch64@1.2.18': optional: true - '@oven/bun-linux-x64-baseline@1.2.17': + '@oven/bun-linux-x64-baseline@1.2.18': optional: true - '@oven/bun-linux-x64-musl-baseline@1.2.17': + '@oven/bun-linux-x64-musl-baseline@1.2.18': optional: true - '@oven/bun-linux-x64-musl@1.2.17': + '@oven/bun-linux-x64-musl@1.2.18': optional: true - '@oven/bun-linux-x64@1.2.17': + '@oven/bun-linux-x64@1.2.18': optional: true - '@oven/bun-windows-x64-baseline@1.2.17': + '@oven/bun-windows-x64-baseline@1.2.18': optional: true - '@oven/bun-windows-x64@1.2.17': + '@oven/bun-windows-x64@1.2.18': optional: true '@oxc-transform/binding-darwin-arm64@0.72.3': @@ -5881,9 +5907,11 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.13.4 - '@types/bun@1.2.17': + '@types/bun@1.2.18(@types/react@19.0.10)': dependencies: - bun-types: 1.2.17 + bun-types: 1.2.18(@types/react@19.0.10) + transitivePeerDependencies: + - '@types/react' '@types/connect@3.4.38': dependencies: @@ -5966,6 +5994,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 22.13.4 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.13.4 @@ -6233,23 +6265,24 @@ snapshots: transitivePeerDependencies: - magicast - bun-types@1.2.17: + bun-types@1.2.18(@types/react@19.0.10): dependencies: '@types/node': 22.13.4 + '@types/react': 19.0.10 - bun@1.2.17: + bun@1.2.18: optionalDependencies: - '@oven/bun-darwin-aarch64': 1.2.17 - '@oven/bun-darwin-x64': 1.2.17 - '@oven/bun-darwin-x64-baseline': 1.2.17 - '@oven/bun-linux-aarch64': 1.2.17 - '@oven/bun-linux-aarch64-musl': 1.2.17 - '@oven/bun-linux-x64': 1.2.17 - '@oven/bun-linux-x64-baseline': 1.2.17 - '@oven/bun-linux-x64-musl': 1.2.17 - '@oven/bun-linux-x64-musl-baseline': 1.2.17 - '@oven/bun-windows-x64': 1.2.17 - '@oven/bun-windows-x64-baseline': 1.2.17 + '@oven/bun-darwin-aarch64': 1.2.18 + '@oven/bun-darwin-x64': 1.2.18 + '@oven/bun-darwin-x64-baseline': 1.2.18 + '@oven/bun-linux-aarch64': 1.2.18 + '@oven/bun-linux-aarch64-musl': 1.2.18 + '@oven/bun-linux-x64': 1.2.18 + '@oven/bun-linux-x64-baseline': 1.2.18 + '@oven/bun-linux-x64-musl': 1.2.18 + '@oven/bun-linux-x64-musl-baseline': 1.2.18 + '@oven/bun-windows-x64': 1.2.18 + '@oven/bun-windows-x64-baseline': 1.2.18 bundle-require@5.1.0(esbuild@0.24.2): dependencies: @@ -7909,7 +7942,7 @@ snapshots: node-releases@2.0.19: {} - node-zugferd@0.0.10-beta.1: + node-zugferd@0.0.10-beta.3: dependencies: defu: 6.1.4 fast-xml-parser: 4.5.2 @@ -8421,6 +8454,8 @@ snapshots: postcss-value-parser: 4.2.0 yoga-wasm-web: 0.3.3 + sax@1.4.1: {} + scheduler@0.25.0: {} scroll-into-view-if-needed@3.1.0: @@ -9067,6 +9102,13 @@ snapshots: ws@8.18.1: {} + xml2js@0.6.2: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xsd-schema-validator@0.10.0: dependencies: which: 5.0.0 diff --git a/tests/basic-test.spec.ts b/tests/basic-test.spec.ts new file mode 100644 index 0000000..94100b8 --- /dev/null +++ b/tests/basic-test.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, beforeEach, expect } from "vitest"; +import { zugferd } from "../packages/node-zugferd/src"; +import { BASIC } from "../packages/node-zugferd/src/profiles/basic/index"; + +import "./helpers/e-invoice-vitest-helper"; + +describe("Basic", () => { + let invoicer: any; + + beforeEach(() => { + invoicer = zugferd({ + profile: BASIC, + strict: false, + }); + }); + + it("basic invoice should generate valid XML", async () => { + const allowanceTotal = 7 + 3; + const lineTotal = 100; + const paidAmount = 5; + + const netTotal = lineTotal - allowanceTotal; + const taxTotal = netTotal * 0.19; + + const xml = await invoicer + .create({ + number: "1234567890", + typeCode: "380", + issueDate: new Date(), + transaction: { + tradeSettlement: { + monetarySummation: { + lineTotalAmount: lineTotal.toFixed(2), + taxBasisTotalAmount: (lineTotal - allowanceTotal).toFixed(2), + allowanceTotalAmount: allowanceTotal.toFixed(2), + taxTotal: { + amount: taxTotal.toFixed(2), + currencyCode: "EUR", + }, + paidAmount: paidAmount.toFixed(2), + grandTotalAmount: (netTotal + taxTotal).toFixed(2), + duePayableAmount: (netTotal + taxTotal - paidAmount).toFixed(2), + }, + allowances: [ + { + actualAmount: "7.00", + reasonCode: "95", + reason: "Discount", + categoryTradeTax: { + categoryCode: "S", + rateApplicablePercent: "19.00", + }, + }, + { + actualAmount: "3.00", + reasonCode: "95", + reason: "Coupon", + categoryTradeTax: { + categoryCode: "S", + rateApplicablePercent: "19.00", + }, + }, + ], + }, + line: [ + { + identifier: "1", + tradeProduct: { + name: "Item 1", + }, + tradeAgreement: { + netTradePrice: { + chargeAmount: "100.00", + }, + }, + tradeDelivery: { + billedQuantity: { + amount: "1", + unitMeasureCode: "C62", + }, + }, + tradeSettlement: { + tradeTax: { + typeCode: "VAT", + categoryCode: "S", + rateApplicablePercent: "19.00", + }, + monetarySummation: { + lineTotalAmount: "100.00", + }, + }, + }, + ], + }, + }) + .toXML(); + + (expect(xml) as any).toBeValidEInvoice(); + }); +}); diff --git a/tests/helpers/e-invoice-vitest-helper.ts b/tests/helpers/e-invoice-vitest-helper.ts new file mode 100644 index 0000000..f946aa0 --- /dev/null +++ b/tests/helpers/e-invoice-vitest-helper.ts @@ -0,0 +1,196 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +import { parseStringPromise } from "xml2js"; +import { expect } from "vitest"; + +import { + formatXmlLocation, + getValueFromXmlJson, + stripXmlNamespace, +} from "./xml-vitest-helper"; + +import { MUSTANG_JAR_PATH } from "../setup/vitest.setup-e2e"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Formats validation errors into a human-friendly, multi-line string for test output. + * Each error includes the message, location, and criterion for easier debugging. + * + * @param errors - Array of validation error objects + * @returns A formatted string summarizing all errors + */ +function formatEInvoiceValidationErrors( + fileName: string, + errors?: Array<{ message: string; location: string; criterion: string }>, + source?: any, +): string { + if (!errors || errors.length === 0) { + return ""; + } + + const stripped = stripXmlNamespace(source); + + return ( + "Invoice XML validation failed with the following errors:" + + "\n" + + "\n" + + errors + .map((e, i) => { + // split on the first of the two characters + // that appear: colon or closing square bracket (:]) + const errorName = e.message.split(/[:\]]/)[0] ?? ""; + const fullErrorName = `${errorName}${e.message.slice( + errorName.length, + errorName.length + 1, + )}`; + let errorMessage = e.message.slice(fullErrorName.length).trim(); + if (errorMessage.startsWith(".") || errorMessage.startsWith("-")) { + errorMessage = errorMessage.slice(1); + } + const location = formatXmlLocation(e.location); + const value = getValueFromXmlJson(stripped, location); + const readableValue = JSON.stringify(value || "- not found -", null, 2) + .split("\n") + .join("\n "); + + return `${i + 1}. ${fullErrorName} @ ${location}\n${errorMessage + .split(". ") + .map( + (x) => + `${x + .split("; ") + .map((y) => ` ${y.trim()}`) + .join(";\n")}`, + ) + .join(".\n")}\n Value: ${readableValue}\n`; + }) + .join("\n") + + "\n" + + `Please check the invoice XML for more details: ${fileName}\n\n` + ); +} + +/** + * Validates the given invoice XML using the Mustang validator JAR. + * + * This function runs the validator via execSync. If the XML is invalid, the process + * will exit with an error, but the error output (stdout) will still contain the XML + * error report. We catch the error, extract the XML output, and proceed to parse it + * so that validation results (including errors) are always available. + * + * @param xml - The invoice XML string to validate + * @returns An object containing the parsed invoice, validation result, and errors + */ +const validateInvoiceXml = async (xml: string) => { + // create a temp directory + fs.mkdirSync(".test-temp", { recursive: true }); + // write the xml file to a temp file with a random name + const fileName = path.join(`.test-temp/invoice-${randomUUID()}.xml`); + fs.writeFileSync(fileName, xml); + + // use Mustang to validate the XML: + /* + java + -Xmx1G + -Dfile.encoding=UTF-8 + -jar ${MUSTANG_JAR_PATH} + --no-notices + --action validate + --source invoice.xml + 2>/dev/null + */ + const command = `java -Xmx1G -Dfile.encoding=UTF-8 -jar ${MUSTANG_JAR_PATH} --no-notices --action validate --source ${fileName} 2>/dev/null`; + + let output: string | undefined; + try { + output = execSync(command, { + encoding: "utf8", + }); + } catch (error: unknown) { + // If validation fails, the error output (stdout) contains the XML error report + output = + error && typeof error === "object" && "stdout" in error + ? (error as { stdout?: string; stderr?: string; message?: string }) + .stdout || + (error as { stderr?: string }).stderr || + (error as { message?: string }).message + : String(error); + } + + // parse the output as XML + const invoice = await parseStringPromise(xml); + const out = await parseStringPromise(output ?? ""); + const v = out.validation; + const messages = out.validation?.xml?.pop()?.messages?.pop() as + | Record< + "error", + { + _: string; + $: { + type: string; + message?: string; + location: string; + criterion: string; + }; + }[] + > + | undefined; + + const validationResult = { + fileName, + source: invoice, + errors: messages?.error?.map( + (e: { + _: string; + $: { + type: string; + message?: string; + location: string; + criterion: string; + }; + }) => ({ + message: e._, + location: e.$.location, + criterion: e.$.criterion, + }), + ), + valid: v.summary?.pop()?.$?.status === "valid", + }; + + // unlinke the temp file if there are no errors + if (!validationResult.errors || validationResult.errors.length === 0) { + fs.unlinkSync(fileName); + } + + return validationResult; +}; + +expect.extend({ + async toBeValidEInvoice(received: string) { + const { source, valid, errors, fileName } = + await validateInvoiceXml(received); + + // If validation fails, throw a readable error for Vitest + if (!valid) { + const formatted = formatEInvoiceValidationErrors( + fileName, + errors, + source, + ); + return { + pass: false, + message: () => formatted, + }; + } + + return { + pass: true, + message: () => "Invoice is valid", + }; + }, +}); diff --git a/tests/helpers/xml-vitest-helper.ts b/tests/helpers/xml-vitest-helper.ts new file mode 100644 index 0000000..daf7f54 --- /dev/null +++ b/tests/helpers/xml-vitest-helper.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function formatXmlLocation(location: string) { + /* + /*: + CrossIndustryInvoice[namespace-uri()='urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'][1]/*: + SupplyChainTradeTransaction[namespace-uri()='urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'][1]/*: + ApplicableHeaderTradeSettlement[namespace-uri()='urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'][1]/*: + SpecifiedTradeSettlementHeaderMonetarySummation[namespace-uri()='urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'][1]/*: + TaxTotalAmount[namespace-uri()='urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'][1] + */ + return `${location || 'UNKNOWN'}` + .split('/*:') + .map((part) => { + const [tag, rest] = part.trim().split('[namespace'); + if (!tag) { + return null; + } + const [, indexRaw] = `${rest || ''}`.split(']['); + const index = `${indexRaw || ''}`.split(']')[0]; + return `${tag}${index ? `[${index}]` : ''}`; + }) + .filter((x) => !!x) + .join('.') + .trim(); +} + +export function stripXmlNamespace(subtree: any): any { + if (typeof subtree !== 'object' || subtree === null) { + return subtree; + } + if (Array.isArray(subtree)) { + return subtree.map((v) => stripXmlNamespace(v)); + } + return Object.fromEntries( + Object.entries(subtree).map(([key, value]) => [ + key.split(':').pop() || key, + stripXmlNamespace(value), + ]), + ); +} + +/** + * Retrieves a value from a JSON representation of an XML document using a dot-separated location path. + * + * The location path uses the format: Tag[index].ChildTag[index]... + * - Tag names in the location do NOT include namespaces (e.g., 'TypeCode' not 'ram:TypeCode'). + * - The XML JSON tree (from xml2js) includes namespaces in keys (e.g., 'ram:TypeCode'). + * - This function ignores namespaces when matching tags. + * - Indices are required if multiple elements exist; default is 0 if omitted. + * + * @param source - The XML JSON object (from xml2js) + * @param location - The dot-separated path (e.g., 'CrossIndustryInvoice[0].TypeCode[1]') + * @returns The value at the specified location, or undefined if not found + */ +export function getValueFromXmlJson(source: any, location: string) { + if (!source || !location) { + return; + } + let current = source; + const parts = location.split('.'); + + for (const part of parts) { + // Extract tag and index (e.g., 'TypeCode[1]') + const match = part.match(/^(\w+)(?:\[(\d+)])?$/); + if (!match) { + return; + } + const tag = match[1]; + const index = match[2] ? Number.parseInt(match[2], 10) : 0; + + // Find the key in current object that matches the tag (ignoring namespace) + if (typeof current !== 'object' || current === null) { + return; + } + current = current[tag]; + if (typeof current !== 'object' || current === null) { + return; + } + + if (!Array.isArray(current) && index === 1) { + continue; + } + + if (!Array.isArray(current) || current.length <= index - 1) { + return; + } + current = current[index - 1]; + } + return current; +} diff --git a/tests/setup/vitest.global-setup-e2e.ts b/tests/setup/vitest.global-setup-e2e.ts new file mode 100644 index 0000000..0089821 --- /dev/null +++ b/tests/setup/vitest.global-setup-e2e.ts @@ -0,0 +1,77 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import fs from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +import type { TestProject } from 'vitest/node'; + +process.env.VITEST = 'true'; + +const MUSTANG_JAR_PATH = 'node_modules/Mustang/Mustang-CLI.jar'; + +export default async function globalSetup(project: TestProject) { + // eslint-disable-next-line no-console + console.log('Starting Global Test Setup...'); + const start = Date.now(); + + await Promise.all([ + (async () => { + console.log('We need java to check the xml e-invoices.'); + console.log('Checking if java is installed...'); + // now check that java is installed + const javaVersion = execSync('java -version', { + encoding: 'utf8', + }); + console.log(`Java version: ${javaVersion}`); + + // check if the file already exists + if (fs.existsSync(MUSTANG_JAR_PATH)) { + console.log(`File already exists at ${MUSTANG_JAR_PATH}`); + return; + } + + // make sure the directory exists + fs.mkdirSync(path.dirname(MUSTANG_JAR_PATH), { recursive: true }); + + // download the Mustang CLI and save it to node_modules/Mustang/Mustang-CLI.jar + const response = await fetch( + 'https://www.mustangproject.org/deploy/Mustang-CLI-2.17.0.jar', + { + method: 'GET', + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to download file: ${response.status} ${response.statusText}`, + ); + } + + const arrayBuffer = await response.arrayBuffer(); + const uint8 = new Uint8Array(arrayBuffer); + + await writeFile(MUSTANG_JAR_PATH, uint8); + console.log(`File downloaded to ${MUSTANG_JAR_PATH}`); + })(), + ]); + + // eslint-disable-next-line no-console + console.log(`...took ${Date.now() - start}ms\n\n`); + + project.provide('globalSetup', { + MUSTANG_JAR_PATH, + }); + + return async () => { + // no-op + }; +} + +declare module 'vitest' { + export interface ProvidedContext { + globalSetup: { + MUSTANG_JAR_PATH: string; + }; + } +} diff --git a/tests/setup/vitest.setup-e2e.ts b/tests/setup/vitest.setup-e2e.ts new file mode 100644 index 0000000..bbbeaf3 --- /dev/null +++ b/tests/setup/vitest.setup-e2e.ts @@ -0,0 +1,10 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { inject } from 'vitest'; + + +process.env.VITEST = 'true'; + +const globalSetup = inject('globalSetup'); + +// Exports for test use +export const MUSTANG_JAR_PATH = globalSetup.MUSTANG_JAR_PATH; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3ae130d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,25 @@ +/* eslint-disable unicorn/import-style,unicorn/prefer-module */ +import { defineConfig } from "vitest/config"; + +/** + * Vitest coverage configuration + * + * Uses the built-in 'v8' provider for code coverage. This enables coverage reporting when running tests with the --coverage flag or via the test:coverage script. + * You can customize include/exclude patterns, reporters, and thresholds as needed. + * See: https://vitest.dev/guide/coverage.html + */ +export default defineConfig({ + test: { + environment: "node", + globalSetup: "./tests/setup/vitest.global-setup-e2e.ts", + setupFiles: ["./tests/setup/vitest.setup-e2e.ts"], + include: ["tests/**/*.spec.ts"], + testTimeout: 60000, + hookTimeout: 60000, + // Run all test files sequentially to avoid DynamoDB table race conditions + fileParallelism: false, + sequence: { + concurrent: false, + }, + }, +});