@open-nav/mock-server 0.1.0
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/LICENSE +29 -0
- package/README.md +77 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +81 -0
- package/dist/bin.js.map +1 -0
- package/dist/handlers.d.ts +73 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +492 -0
- package/dist/handlers.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +51 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +123 -0
- package/dist/server.js.map +1 -0
- package/dist/state.d.ts +73 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +12 -0
- package/dist/state.js.map +1 -0
- package/package.json +58 -0
- package/src/bin.ts +86 -0
- package/src/handlers.ts +733 -0
- package/src/index.ts +3 -0
- package/src/server.ts +192 -0
- package/src/state.ts +80 -0
package/src/index.ts
ADDED
package/src/server.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import { encodeInvoiceData, parseDocument, type InvoiceData } from '@open-nav/core';
|
|
4
|
+
import {
|
|
5
|
+
HANDLERS,
|
|
6
|
+
MockError,
|
|
7
|
+
errorResponse,
|
|
8
|
+
type HandlerConfig,
|
|
9
|
+
type MockCredentials,
|
|
10
|
+
} from './handlers.js';
|
|
11
|
+
import { createState, type MockState, type MockTaxpayer } from './state.js';
|
|
12
|
+
|
|
13
|
+
export interface MockServerOptions {
|
|
14
|
+
/** Credentials the mock accepts. Anything else is INVALID_SECURITY_USER. */
|
|
15
|
+
credentials: MockCredentials;
|
|
16
|
+
/**
|
|
17
|
+
* Invoices issued *to* this taxpayer, answered by an INBOUND query.
|
|
18
|
+
*
|
|
19
|
+
* Submitting an invoice makes it outbound; there is no way to receive one,
|
|
20
|
+
* so inbound invoices are seeded here.
|
|
21
|
+
*/
|
|
22
|
+
inboundInvoices?: InvoiceData[];
|
|
23
|
+
/** Taxpayers `queryTaxpayer` knows about. */
|
|
24
|
+
taxpayers?: MockTaxpayer[];
|
|
25
|
+
/** Port to listen on. 0, the default, picks a free one. */
|
|
26
|
+
port?: number;
|
|
27
|
+
host?: string;
|
|
28
|
+
/** Polls before a transaction settles. 0 means immediately. */
|
|
29
|
+
pollsBeforeDone?: number;
|
|
30
|
+
/** Validate submitted invoices and abort the invalid ones. Default true. */
|
|
31
|
+
validate?: boolean;
|
|
32
|
+
/** Injectable clock. */
|
|
33
|
+
now?: () => Date;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MockServer {
|
|
37
|
+
/** Base URL to hand to a `NavClient`, including the service path. */
|
|
38
|
+
url: string;
|
|
39
|
+
port: number;
|
|
40
|
+
/** Everything the mock has recorded, for assertions. */
|
|
41
|
+
state: MockState;
|
|
42
|
+
close: () => Promise<void>;
|
|
43
|
+
server: Server;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start a local stand-in for the Online Számla invoice service.
|
|
48
|
+
*
|
|
49
|
+
* It speaks the real XML on the real paths, verifies the request signature
|
|
50
|
+
* the way NAV does, and decides an invoice's fate by running it through this
|
|
51
|
+
* project's validator. That last part is what makes it useful: a test that
|
|
52
|
+
* submits a broken invoice sees a realistic ABORTED result with the fault
|
|
53
|
+
* code NAV would have reported.
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* const mock = await startMockServer({ credentials });
|
|
57
|
+
* const client = new NavClient({ credentials, software, baseUrl: mock.url });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export async function startMockServer(options: MockServerOptions): Promise<MockServer> {
|
|
61
|
+
const state = createState(options.taxpayers ?? []);
|
|
62
|
+
seedInbound(state, options.inboundInvoices ?? [], options.now ?? (() => new Date()));
|
|
63
|
+
const config: HandlerConfig = {
|
|
64
|
+
credentials: options.credentials,
|
|
65
|
+
pollsBeforeDone: options.pollsBeforeDone ?? 0,
|
|
66
|
+
validate: options.validate ?? true,
|
|
67
|
+
now: options.now ?? (() => new Date()),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const server = createServer((request, response) => {
|
|
71
|
+
const chunks: Buffer[] = [];
|
|
72
|
+
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
73
|
+
request.on('end', () => {
|
|
74
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
75
|
+
const { status, body: responseBody } = dispatch(
|
|
76
|
+
request.method ?? '',
|
|
77
|
+
request.url ?? '',
|
|
78
|
+
body,
|
|
79
|
+
config,
|
|
80
|
+
state,
|
|
81
|
+
);
|
|
82
|
+
response.writeHead(status, { 'content-type': 'application/xml; charset=utf-8' });
|
|
83
|
+
response.end(responseBody);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await new Promise<void>((resolve) => {
|
|
88
|
+
server.listen(options.port ?? 0, options.host ?? '127.0.0.1', resolve);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const address = server.address() as AddressInfo;
|
|
92
|
+
const host = options.host ?? '127.0.0.1';
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
url: `http://${host}:${address.port}/invoiceService/v1`,
|
|
96
|
+
port: address.port,
|
|
97
|
+
state,
|
|
98
|
+
server,
|
|
99
|
+
close: () =>
|
|
100
|
+
new Promise<void>((resolve, reject) => {
|
|
101
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Store seeded invoices as if they had been received. */
|
|
107
|
+
function seedInbound(state: MockState, invoices: InvoiceData[], now: () => Date): void {
|
|
108
|
+
for (const [index, invoice] of invoices.entries()) {
|
|
109
|
+
const head = invoice.invoiceMain.invoice?.invoiceHead;
|
|
110
|
+
state.inbound.set(invoice.invoiceNumber, {
|
|
111
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
112
|
+
operation: 'CREATE',
|
|
113
|
+
supplierTaxNumber: head?.supplierInfo.supplierTaxNumber.taxpayerId ?? '',
|
|
114
|
+
...(head?.customerInfo?.customerVatData?.customerTaxNumber?.taxpayerId
|
|
115
|
+
? { customerTaxNumber: head.customerInfo.customerVatData.customerTaxNumber.taxpayerId }
|
|
116
|
+
: {}),
|
|
117
|
+
issueDate: invoice.invoiceIssueDate,
|
|
118
|
+
base64: encodeInvoiceData(invoice),
|
|
119
|
+
compressed: false,
|
|
120
|
+
invoice,
|
|
121
|
+
transactionId: `MOCKIN${(index + 1).toString().padStart(6, '0')}`,
|
|
122
|
+
index: index + 1,
|
|
123
|
+
insDate: now().toISOString(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function dispatch(
|
|
129
|
+
method: string,
|
|
130
|
+
url: string,
|
|
131
|
+
body: string,
|
|
132
|
+
config: HandlerConfig,
|
|
133
|
+
state: MockState,
|
|
134
|
+
): { status: number; body: string } {
|
|
135
|
+
if (method !== 'POST') {
|
|
136
|
+
return {
|
|
137
|
+
status: 405,
|
|
138
|
+
body: errorResponse(config, 'INVALID_REQUEST', 'only POST is supported'),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const operation = url.split('?')[0]?.split('/').filter(Boolean).pop() ?? '';
|
|
143
|
+
const handler = HANDLERS[operation];
|
|
144
|
+
if (!handler) {
|
|
145
|
+
return {
|
|
146
|
+
status: 404,
|
|
147
|
+
body: errorResponse(config, 'INVALID_REQUEST', `unknown operation ${operation}`),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let document;
|
|
152
|
+
try {
|
|
153
|
+
// Lenient, because a caller's extra element should surface as a specific
|
|
154
|
+
// complaint from a handler rather than as an opaque parse failure.
|
|
155
|
+
document = parseDocument(body, { unknownElements: 'ignore' });
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
return {
|
|
158
|
+
status: 400,
|
|
159
|
+
body: errorResponse(
|
|
160
|
+
config,
|
|
161
|
+
'INVALID_REQUEST',
|
|
162
|
+
`request XML could not be parsed: ${(cause as Error).message}`,
|
|
163
|
+
),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
state.requests.push({
|
|
168
|
+
operation,
|
|
169
|
+
requestId: (document.value as { header?: { requestId?: string } }).header?.requestId ?? '',
|
|
170
|
+
body,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
return handler(
|
|
175
|
+
{
|
|
176
|
+
operation,
|
|
177
|
+
body,
|
|
178
|
+
document: { root: document.root, value: document.value as Record<string, unknown> },
|
|
179
|
+
},
|
|
180
|
+
config,
|
|
181
|
+
state,
|
|
182
|
+
);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof MockError) {
|
|
185
|
+
return { status: error.status, body: errorResponse(config, error.errorCode, error.message) };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
status: 500,
|
|
189
|
+
body: errorResponse(config, 'OPERATION_FAILED', (error as Error).message),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { InvoiceData, InvoiceStatusType } from '@open-nav/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everything the mock service remembers.
|
|
5
|
+
*
|
|
6
|
+
* Held in memory and exposed on the server handle, so a test can assert on
|
|
7
|
+
* what was actually received rather than only on what came back.
|
|
8
|
+
*/
|
|
9
|
+
export interface MockState {
|
|
10
|
+
/** Invoices accepted, keyed by invoice number. */
|
|
11
|
+
invoices: Map<string, StoredInvoice>;
|
|
12
|
+
/** Invoices issued *to* this taxpayer, seeded by the test. */
|
|
13
|
+
inbound: Map<string, StoredInvoice>;
|
|
14
|
+
/** Transactions, keyed by transaction id. */
|
|
15
|
+
transactions: Map<string, StoredTransaction>;
|
|
16
|
+
/** Every `requestId` seen, since NAV rejects a replayed one. */
|
|
17
|
+
requestIds: Set<string>;
|
|
18
|
+
/** Exchange tokens issued and not yet spent. */
|
|
19
|
+
tokens: Map<string, { issuedAt: number; spent: boolean }>;
|
|
20
|
+
/** Taxpayers the mock knows about, keyed by 8 digit core tax number. */
|
|
21
|
+
taxpayers: Map<string, MockTaxpayer>;
|
|
22
|
+
/** Requests received, newest last, for assertions. */
|
|
23
|
+
requests: Array<{ operation: string; requestId: string; body: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StoredInvoice {
|
|
27
|
+
invoiceNumber: string;
|
|
28
|
+
/** `CREATE`, `MODIFY` or `STORNO`. */
|
|
29
|
+
operation: string;
|
|
30
|
+
supplierTaxNumber: string;
|
|
31
|
+
customerTaxNumber?: string;
|
|
32
|
+
issueDate: string;
|
|
33
|
+
/** The payload exactly as submitted, still base64. */
|
|
34
|
+
base64: string;
|
|
35
|
+
compressed: boolean;
|
|
36
|
+
invoice: InvoiceData;
|
|
37
|
+
transactionId: string;
|
|
38
|
+
index: number;
|
|
39
|
+
insDate: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StoredTransaction {
|
|
43
|
+
transactionId: string;
|
|
44
|
+
insDate: string;
|
|
45
|
+
/** Poll count, used to simulate asynchronous processing. */
|
|
46
|
+
polls: number;
|
|
47
|
+
results: Array<{
|
|
48
|
+
index: number;
|
|
49
|
+
invoiceStatus: InvoiceStatusType;
|
|
50
|
+
/** Findings, mirroring NAV's split between technical and business faults. */
|
|
51
|
+
businessValidationMessages: Array<{
|
|
52
|
+
validationResultCode: 'ERROR' | 'WARN' | 'INFO';
|
|
53
|
+
validationErrorCode?: string;
|
|
54
|
+
message?: string;
|
|
55
|
+
}>;
|
|
56
|
+
}>;
|
|
57
|
+
/** Status the results settle on once processing finishes. */
|
|
58
|
+
finalStatuses: InvoiceStatusType[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MockTaxpayer {
|
|
62
|
+
taxNumber: string;
|
|
63
|
+
name: string;
|
|
64
|
+
shortName?: string;
|
|
65
|
+
valid: boolean;
|
|
66
|
+
vatCode?: string;
|
|
67
|
+
countyCode?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createState(taxpayers: MockTaxpayer[] = []): MockState {
|
|
71
|
+
return {
|
|
72
|
+
invoices: new Map(),
|
|
73
|
+
inbound: new Map(),
|
|
74
|
+
transactions: new Map(),
|
|
75
|
+
requestIds: new Set(),
|
|
76
|
+
tokens: new Map(),
|
|
77
|
+
taxpayers: new Map(taxpayers.map((taxpayer) => [taxpayer.taxNumber, taxpayer])),
|
|
78
|
+
requests: [],
|
|
79
|
+
};
|
|
80
|
+
}
|