@webda/grpc 4.0.0-beta.3

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,1092 @@
1
+ import { suite, test } from "@webda/test";
2
+ import * as assert from "assert";
3
+ import { vi } from "vitest";
4
+ import { existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { EventEmitter } from "node:events";
8
+
9
+ // Tracked operations map for the mock
10
+ const mockOperations: Record<string, any> = {};
11
+ const mockSchemas: Record<string, any> = {};
12
+ const coreEventCallbacks: Record<string, Function> = {};
13
+ let mockCallOperationResult: any = {};
14
+ let mockCallOperationError: any = null;
15
+ /** If set, callOperation will use this as the raw _output string (instead of JSON.stringify(mockCallOperationResult)) */
16
+ let mockCallOperationRawOutput: string | undefined = undefined;
17
+ /** Services returned by the mocked useCore().getServices() — set per test to inject HttpServer stubs */
18
+ let mockCoreServices: Record<string, any> = {};
19
+ /** Models returned by the mocked useApplication().getModels() — used by build() */
20
+ let mockModels: Record<string, any> = {};
21
+
22
+ vi.mock("@webda/core", () => {
23
+ class MockServiceParameters {
24
+ load(params: any = {}) {
25
+ Object.assign(this, params);
26
+ return this;
27
+ }
28
+ }
29
+
30
+ class MockOperationsTransportParameters extends MockServiceParameters {
31
+ operations?: string[];
32
+ load(params: any = {}) {
33
+ super.load(params);
34
+ this.operations ??= ["*"];
35
+ return this;
36
+ }
37
+ }
38
+
39
+ class MockService {
40
+ parameters: any;
41
+ _name: string;
42
+
43
+ constructor(name?: string, params?: any) {
44
+ this._name = name || "test";
45
+ this.parameters = params || {};
46
+ }
47
+
48
+ log(_level: string, ..._args: any[]) {}
49
+
50
+ async resolve() {
51
+ return this;
52
+ }
53
+
54
+ async init() {
55
+ return this;
56
+ }
57
+
58
+ async stop() {}
59
+ }
60
+
61
+ class MockOperationsTransport extends MockService {
62
+ getOperations() {
63
+ return mockOperations;
64
+ }
65
+
66
+ exposeOperation(_opId: string, _def: any) {}
67
+ }
68
+
69
+ return {
70
+ Service: MockService,
71
+ ServiceParameters: MockServiceParameters,
72
+ OperationsTransport: MockOperationsTransport,
73
+ OperationsTransportParameters: MockOperationsTransportParameters,
74
+ OperationDefinition: {},
75
+ callOperation: async (ctx: any, opId: string) => {
76
+ if (mockCallOperationError) {
77
+ throw mockCallOperationError;
78
+ }
79
+ if (mockCallOperationRawOutput !== undefined) {
80
+ ctx._output = mockCallOperationRawOutput;
81
+ } else {
82
+ ctx._output = JSON.stringify(mockCallOperationResult);
83
+ }
84
+ },
85
+ OperationContext: class {},
86
+ SimpleOperationContext: class {
87
+ _input: Buffer;
88
+ _output: string;
89
+
90
+ async init() {
91
+ return this;
92
+ }
93
+
94
+ setInput(buf: Buffer) {
95
+ this._input = buf;
96
+ }
97
+
98
+ getOutput() {
99
+ return this._output;
100
+ }
101
+ },
102
+ WebContext: class {},
103
+ useCoreEvents: (eventName: string, callback: Function) => {
104
+ coreEventCallbacks[eventName] = callback;
105
+ return () => {
106
+ delete coreEventCallbacks[eventName];
107
+ };
108
+ },
109
+ useApplication: () => ({
110
+ getSchemas: () => mockSchemas,
111
+ getModels: () => mockModels
112
+ }),
113
+ useCore: () => ({ getServices: () => mockCoreServices }),
114
+ useInstanceStorage: () => ({}),
115
+ runWithInstanceStorage: (_storage: any, fn: () => any) => fn(),
116
+ Command: () => () => {},
117
+ BuildCommand: () => () => {}
118
+ };
119
+ });
120
+
121
+ vi.mock("@webda/workout", () => ({
122
+ useLog: () => ({
123
+ info() {},
124
+ warn() {},
125
+ debug() {},
126
+ error() {}
127
+ })
128
+ }));
129
+
130
+ vi.mock("@grpc/proto-loader", () => ({
131
+ loadSync: vi.fn(function loadSync() {
132
+ return {};
133
+ })
134
+ }));
135
+
136
+ // Dynamic import so mocks are applied first
137
+ const { GrpcService, GrpcServiceParameters } = await import("./grpcservice.service.js");
138
+ const { GrpcStatus } = await import("./grpc-stream.js");
139
+
140
+ @suite
141
+ class GrpcServiceParametersTest {
142
+ @test
143
+ defaultValues() {
144
+ const params = new GrpcServiceParameters();
145
+ params.load({});
146
+ assert.strictEqual(params.protoFile, ".webda/app.proto");
147
+ assert.strictEqual(params.packageName, "webda");
148
+ }
149
+
150
+ @test
151
+ customValues() {
152
+ const params = new GrpcServiceParameters();
153
+ params.load({
154
+ protoFile: "custom.proto",
155
+ packageName: "myapp"
156
+ });
157
+ assert.strictEqual(params.protoFile, "custom.proto");
158
+ assert.strictEqual(params.packageName, "myapp");
159
+ }
160
+
161
+ @test
162
+ defaultsNotOverriddenWhenValueProvided() {
163
+ const params = new GrpcServiceParameters();
164
+ params.load({ protoFile: "my.proto" });
165
+ assert.strictEqual(params.protoFile, "my.proto");
166
+ assert.strictEqual(params.packageName, "webda");
167
+ }
168
+ }
169
+
170
+ @suite
171
+ class GrpcServiceExposeOperationTest {
172
+ @test
173
+ exposeOperationIsNoOp() {
174
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
175
+
176
+ // Should not throw or do anything
177
+ service.exposeOperation("Post.Create", { service: "PostService", method: "create" });
178
+ service.exposeOperation("User.Get", { service: "UserService", method: "get" });
179
+ }
180
+ }
181
+
182
+ @suite
183
+ class GrpcServiceCountServicesTest {
184
+ @test
185
+ countDistinctPrefixes() {
186
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
187
+
188
+ const operations = {
189
+ "Post.Create": {},
190
+ "Post.Get": {},
191
+ "User.Login": {},
192
+ "User.Logout": {},
193
+ "Admin.Stats": {}
194
+ };
195
+
196
+ const count = (service as any).countServices(operations);
197
+ assert.strictEqual(count, 3);
198
+ }
199
+
200
+ @test
201
+ operationsWithoutDotUseDefault() {
202
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
203
+
204
+ const operations = {
205
+ Health: {},
206
+ Ping: {},
207
+ "Post.Create": {}
208
+ };
209
+
210
+ // "Health" and "Ping" both map to "Default", plus "Post"
211
+ const count = (service as any).countServices(operations);
212
+ assert.strictEqual(count, 2);
213
+ }
214
+
215
+ @test
216
+ emptyOperations() {
217
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
218
+ const count = (service as any).countServices({});
219
+ assert.strictEqual(count, 0);
220
+ }
221
+ }
222
+
223
+ @suite
224
+ class GrpcServiceErrorMappingTest {
225
+ @test
226
+ maps400ToInvalidArgument() {
227
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
228
+ const err = { getResponseCode: () => 400, message: "Bad request" };
229
+ assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.INVALID_ARGUMENT);
230
+ }
231
+
232
+ @test
233
+ maps401ToUnauthenticated() {
234
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
235
+ const err = { getResponseCode: () => 401, message: "Unauthorized" };
236
+ assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.UNAUTHENTICATED);
237
+ }
238
+
239
+ @test
240
+ maps403ToPermissionDenied() {
241
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
242
+ const err = { getResponseCode: () => 403, message: "Forbidden" };
243
+ assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.PERMISSION_DENIED);
244
+ }
245
+
246
+ @test
247
+ maps404ToNotFound() {
248
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
249
+ const err = { getResponseCode: () => 404, message: "Not found" };
250
+ assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.NOT_FOUND);
251
+ }
252
+
253
+ @test
254
+ maps409ToAlreadyExists() {
255
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
256
+ const err = { getResponseCode: () => 409, message: "Conflict" };
257
+ assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.ALREADY_EXISTS);
258
+ }
259
+
260
+ @test
261
+ maps429ToResourceExhausted() {
262
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
263
+ const err = { getResponseCode: () => 429, message: "Too many requests" };
264
+ assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.RESOURCE_EXHAUSTED);
265
+ }
266
+
267
+ @test
268
+ mapsUnknownCodeToInternal() {
269
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
270
+ const err = { getResponseCode: () => 500, message: "Server error" };
271
+ assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.INTERNAL);
272
+ }
273
+
274
+ @test
275
+ mapsErrorWithoutGetResponseCodeToInternal() {
276
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
277
+ const err = new Error("plain error");
278
+ assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.INTERNAL);
279
+ }
280
+
281
+ @test
282
+ mapsNullErrorToInternal() {
283
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
284
+ assert.strictEqual((service as any).errorToGrpcStatus(null), GrpcStatus.INTERNAL);
285
+ assert.strictEqual((service as any).errorToGrpcStatus(undefined), GrpcStatus.INTERNAL);
286
+ }
287
+ }
288
+
289
+ @suite
290
+ class GrpcServiceGenerateProtoTest {
291
+ private tmpDir: string;
292
+
293
+ beforeEach() {
294
+ this.tmpDir = join(tmpdir(), `grpc-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
295
+ mkdirSync(this.tmpDir, { recursive: true });
296
+
297
+ // Clear mockOperations
298
+ for (const key of Object.keys(mockOperations)) {
299
+ delete mockOperations[key];
300
+ }
301
+ for (const key of Object.keys(mockSchemas)) {
302
+ delete mockSchemas[key];
303
+ }
304
+ }
305
+
306
+ afterEach() {
307
+ try {
308
+ rmSync(this.tmpDir, { recursive: true, force: true });
309
+ } catch {
310
+ // Ignore cleanup errors
311
+ }
312
+ }
313
+
314
+ @test
315
+ async writesProtoFile() {
316
+ mockOperations["Post.Create"] = {
317
+ service: "PostService",
318
+ method: "create"
319
+ };
320
+ mockOperations["Post.Get"] = {
321
+ service: "PostService",
322
+ method: "get"
323
+ };
324
+
325
+ const outPath = join(this.tmpDir, "output", "app.proto");
326
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: outPath }));
327
+
328
+ await service.build();
329
+
330
+ assert.ok(existsSync(outPath), "Proto file should be written");
331
+ const content = readFileSync(outPath, "utf-8");
332
+ assert.ok(content.includes('syntax = "proto3"'), "Should contain proto3 syntax");
333
+ assert.ok(content.includes("service PostService {"), "Should contain PostService");
334
+ assert.ok(content.includes("rpc Create"), "Should contain Create rpc");
335
+ assert.ok(content.includes("rpc Get"), "Should contain Get rpc");
336
+ }
337
+
338
+ @test
339
+ async writesProtoFileToConfiguredPath() {
340
+ mockOperations["Health"] = {
341
+ service: "HealthService",
342
+ method: "check"
343
+ };
344
+
345
+ const customPath = join(this.tmpDir, "custom.proto");
346
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: customPath }));
347
+
348
+ await service.build();
349
+
350
+ assert.ok(existsSync(customPath), "Proto file should be written to configured path");
351
+ const content = readFileSync(customPath, "utf-8");
352
+ assert.ok(content.includes("service DefaultService {"), "Should contain DefaultService");
353
+ }
354
+
355
+ @test
356
+ async usesCustomPackageName() {
357
+ mockOperations["Test.Ping"] = {};
358
+
359
+ const outPath = join(this.tmpDir, "test.proto");
360
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: outPath, packageName: "myapp" }));
361
+
362
+ await service.build();
363
+
364
+ const content = readFileSync(outPath, "utf-8");
365
+ assert.ok(content.includes("package myapp;"), "Should use custom package name");
366
+ }
367
+
368
+ @test
369
+ async createsDirectoryIfNotExists() {
370
+ mockOperations["Test.Op"] = {};
371
+
372
+ const deepPath = join(this.tmpDir, "deep", "nested", "dir", "app.proto");
373
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: deepPath }));
374
+
375
+ await service.build();
376
+
377
+ assert.ok(existsSync(deepPath), "Should create nested directories and write file");
378
+ }
379
+
380
+ @test
381
+ async foldsModelInputSchemasIntoSchemasMap() {
382
+ // app.getModels() returns models whose Metadata.Schemas.Input should be
383
+ // merged into the schema map used for proto generation.
384
+ mockOperations["Post.Create"] = { service: "PostService", method: "create" };
385
+ mockModels = {
386
+ "WebdaSample/Post": {
387
+ Metadata: { Schemas: { Input: { type: "object", properties: { title: { type: "string" } } } } }
388
+ }
389
+ };
390
+ const outPath = join(this.tmpDir, "with-model.proto");
391
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: outPath }));
392
+ try {
393
+ await service.build();
394
+ assert.ok(existsSync(outPath), "Proto file should be written");
395
+ } finally {
396
+ mockModels = {};
397
+ }
398
+ }
399
+
400
+ @test
401
+ async emptyOperationsStillProducesValidProto() {
402
+ const outPath = join(this.tmpDir, "empty.proto");
403
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: outPath }));
404
+
405
+ await service.build();
406
+
407
+ assert.ok(existsSync(outPath), "Proto file should be written even with no operations");
408
+ const content = readFileSync(outPath, "utf-8");
409
+ assert.ok(content.includes('syntax = "proto3"'));
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Create a gRPC frame from a JSON message
415
+ */
416
+ function createGrpcFrame(data: any): Buffer {
417
+ const payload = Buffer.from(JSON.stringify(data));
418
+ const frame = Buffer.alloc(5 + payload.length);
419
+ frame.writeUInt8(0, 0);
420
+ frame.writeUInt32BE(payload.length, 1);
421
+ payload.copy(frame, 5);
422
+ return frame;
423
+ }
424
+
425
+ /**
426
+ * Create a mock request EventEmitter that simulates an HTTP request with gRPC data
427
+ */
428
+ function createMockReq(url: string, frameData?: any): EventEmitter & { url: string; headers: Record<string, string> } {
429
+ const req = new EventEmitter() as any;
430
+ req.url = url;
431
+ req.headers = { "content-type": "application/grpc" };
432
+
433
+ if (frameData !== undefined) {
434
+ // Schedule data emission on next tick so handlers can be registered
435
+ process.nextTick(() => {
436
+ const frame = createGrpcFrame(frameData);
437
+ req.emit("data", frame);
438
+ // For client/bidi streaming, also emit end
439
+ req.emit("end");
440
+ });
441
+ }
442
+
443
+ return req;
444
+ }
445
+
446
+ /**
447
+ * Create a mock response that captures what was written
448
+ */
449
+ function createMockRes(): {
450
+ res: any;
451
+ written: Buffer[];
452
+ trailers: Record<string, string> | null;
453
+ ended: boolean;
454
+ headStatus: number;
455
+ headHeaders: Record<string, string>;
456
+ headers: Record<string, string>;
457
+ } {
458
+ const state = {
459
+ written: [] as Buffer[],
460
+ trailers: null as Record<string, string> | null,
461
+ ended: false,
462
+ headStatus: 0,
463
+ headHeaders: {} as Record<string, string>,
464
+ headers: {} as Record<string, string>,
465
+ res: null as any
466
+ };
467
+
468
+ state.res = {
469
+ setHeader(key: string, value: string) {
470
+ state.headers[key] = value;
471
+ },
472
+ writeHead(status: number, headers: any) {
473
+ state.headStatus = status;
474
+ state.headHeaders = headers || {};
475
+ },
476
+ write(data: Buffer) {
477
+ state.written.push(Buffer.from(data));
478
+ },
479
+ addTrailers(t: Record<string, string>) {
480
+ state.trailers = t;
481
+ },
482
+ end() {
483
+ state.ended = true;
484
+ }
485
+ };
486
+
487
+ return state;
488
+ }
489
+
490
+ /**
491
+ * Create a JSON-based method definition mock
492
+ */
493
+ function createJsonMethodDef() {
494
+ return {
495
+ path: "/webda.TestService/TestMethod",
496
+ requestSerialize: (msg: any) => Buffer.from(JSON.stringify(msg)),
497
+ requestDeserialize: (buf: Buffer) => JSON.parse(buf.toString()),
498
+ responseSerialize: (msg: any) => Buffer.from(JSON.stringify(msg)),
499
+ responseDeserialize: (buf: Buffer) => JSON.parse(buf.toString()),
500
+ requestStream: false,
501
+ responseStream: false
502
+ };
503
+ }
504
+
505
+ /**
506
+ * Set up a service with a mapped rpc method
507
+ */
508
+ function setupServiceWithRpc(opId: string, opDef: any = {}): InstanceType<typeof GrpcService> {
509
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
510
+ const methodDef = createJsonMethodDef();
511
+ const grpcPath = `/webda.TestService/${opId.split(".").pop()}`;
512
+
513
+ // Manually set up the rpcToOperation and rpcMethods maps
514
+ (service as any).rpcToOperation.set(grpcPath, opId);
515
+ (service as any).rpcMethods.set(grpcPath, methodDef);
516
+
517
+ // Set up the mock operation
518
+ mockOperations[opId] = opDef;
519
+
520
+ return service;
521
+ }
522
+
523
+ @suite
524
+ class GrpcServiceHandleGrpcRequestTest {
525
+ beforeEach() {
526
+ // Clear operations
527
+ for (const key of Object.keys(mockOperations)) {
528
+ delete mockOperations[key];
529
+ }
530
+ mockCallOperationResult = {};
531
+ mockCallOperationError = null;
532
+ mockCallOperationRawOutput = undefined;
533
+ }
534
+
535
+ @test
536
+ async unimplementedMethodReturns12() {
537
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
538
+
539
+ let writtenHeaders: any = {};
540
+ let ended = false;
541
+ const req = { url: "/webda.UnknownService/UnknownMethod" };
542
+ const res = {
543
+ writeHead(status: number, headers: any) {
544
+ writtenHeaders = headers;
545
+ },
546
+ end() {
547
+ ended = true;
548
+ }
549
+ };
550
+
551
+ await service.handleGrpcRequest(req as any, res as any);
552
+
553
+ assert.strictEqual(writtenHeaders["grpc-status"], String(GrpcStatus.UNIMPLEMENTED));
554
+ const decodedMessage = decodeURIComponent(writtenHeaders["grpc-message"]);
555
+ assert.ok(decodedMessage.includes("Method not found"), `Expected 'Method not found' in: ${decodedMessage}`);
556
+ assert.ok(ended);
557
+ }
558
+
559
+ @test
560
+ async unaryRequestReturnsResponse() {
561
+ const service = setupServiceWithRpc("Test.DoSomething", {});
562
+ mockCallOperationResult = { success: true, id: "123" };
563
+
564
+ const grpcPath = "/webda.TestService/DoSomething";
565
+ const req = createMockReq(grpcPath, { input: "hello" });
566
+ const mock = createMockRes();
567
+
568
+ await service.handleGrpcRequest(req as any, mock.res);
569
+
570
+ // Should have written a response frame
571
+ assert.strictEqual(mock.written.length, 1);
572
+ const payload = mock.written[0].subarray(5);
573
+ const response = JSON.parse(payload.toString());
574
+ assert.deepStrictEqual(response, { success: true, id: "123" });
575
+
576
+ // Should have ended with status 0
577
+ assert.ok(mock.trailers !== null);
578
+ assert.strictEqual(mock.trailers!["grpc-status"], "0");
579
+ assert.ok(mock.ended);
580
+ }
581
+
582
+ @test
583
+ async unaryWrapsScalarOutputInValueField() {
584
+ // When the operation returns a JSON-parsed scalar (not an object), wrap in {value}
585
+ const service = setupServiceWithRpc("Test.GetVersion", {});
586
+ mockCallOperationRawOutput = '"1.2.3"';
587
+
588
+ const grpcPath = "/webda.TestService/GetVersion";
589
+ const req = createMockReq(grpcPath, {});
590
+ const mock = createMockRes();
591
+ await service.handleGrpcRequest(req as any, mock.res);
592
+ assert.strictEqual(mock.written.length, 1);
593
+ const response = JSON.parse(mock.written[0].subarray(5).toString());
594
+ assert.deepStrictEqual(response, { value: "1.2.3" });
595
+ }
596
+
597
+ @test
598
+ async unaryWrapsUnparseableOutputInValueField() {
599
+ // When the operation output isn't valid JSON, fall through to {value: rawString}
600
+ const service = setupServiceWithRpc("Test.Raw", {});
601
+ mockCallOperationRawOutput = "not-json-at-all";
602
+
603
+ const grpcPath = "/webda.TestService/Raw";
604
+ const req = createMockReq(grpcPath, {});
605
+ const mock = createMockRes();
606
+ await service.handleGrpcRequest(req as any, mock.res);
607
+ assert.strictEqual(mock.written.length, 1);
608
+ const response = JSON.parse(mock.written[0].subarray(5).toString());
609
+ assert.deepStrictEqual(response, { value: "not-json-at-all" });
610
+ }
611
+
612
+ @test
613
+ async unaryRequestHandlesOperationError() {
614
+ const service = setupServiceWithRpc("Test.FailOp", {});
615
+ mockCallOperationError = {
616
+ getResponseCode: () => 404,
617
+ message: "Resource not found"
618
+ };
619
+
620
+ const grpcPath = "/webda.TestService/FailOp";
621
+ const req = createMockReq(grpcPath, { id: "missing" });
622
+ const mock = createMockRes();
623
+
624
+ await service.handleGrpcRequest(req as any, mock.res);
625
+
626
+ // Should have ended with error status
627
+ assert.ok(mock.trailers !== null);
628
+ assert.strictEqual(mock.trailers!["grpc-status"], String(GrpcStatus.NOT_FOUND));
629
+ assert.ok(mock.ended);
630
+ }
631
+
632
+ @test
633
+ async serverStreamingReturnsMultipleMessages() {
634
+ const service = setupServiceWithRpc("Test.StreamItems", {
635
+ grpc: { streaming: "server" }
636
+ });
637
+ mockCallOperationRawOutput = '{"id":1}\n{"id":2}\n{"id":3}';
638
+
639
+ const grpcPath = "/webda.TestService/StreamItems";
640
+ const req = createMockReq(grpcPath, { query: "all" });
641
+ const mock = createMockRes();
642
+
643
+ await service.handleGrpcRequest(req as any, mock.res);
644
+
645
+ // Should have written 3 response frames (one per NDJSON line)
646
+ assert.strictEqual(mock.written.length, 3, `Expected 3 frames, got ${mock.written.length}`);
647
+
648
+ const msg1 = JSON.parse(mock.written[0].subarray(5).toString());
649
+ const msg2 = JSON.parse(mock.written[1].subarray(5).toString());
650
+ const msg3 = JSON.parse(mock.written[2].subarray(5).toString());
651
+ assert.deepStrictEqual(msg1, { id: 1 });
652
+ assert.deepStrictEqual(msg2, { id: 2 });
653
+ assert.deepStrictEqual(msg3, { id: 3 });
654
+
655
+ // Should end with OK
656
+ assert.strictEqual(mock.trailers!["grpc-status"], "0");
657
+ }
658
+
659
+ @test
660
+ async serverStreamingHandlesNonJsonLines() {
661
+ const service = setupServiceWithRpc("Test.StreamRaw", {
662
+ grpc: { streaming: "server" }
663
+ });
664
+ mockCallOperationRawOutput = 'not-json-line\n{"valid":true}';
665
+
666
+ const grpcPath = "/webda.TestService/StreamRaw";
667
+ const req = createMockReq(grpcPath, {});
668
+ const mock = createMockRes();
669
+
670
+ await service.handleGrpcRequest(req as any, mock.res);
671
+
672
+ // First line is not valid JSON, should be wrapped in { data: ... }
673
+ assert.strictEqual(mock.written.length, 2);
674
+ const msg1 = JSON.parse(mock.written[0].subarray(5).toString());
675
+ assert.deepStrictEqual(msg1, { data: "not-json-line" });
676
+ const msg2 = JSON.parse(mock.written[1].subarray(5).toString());
677
+ assert.deepStrictEqual(msg2, { valid: true });
678
+ }
679
+
680
+ @test
681
+ async serverStreamingHandlesNoOutput() {
682
+ const service = setupServiceWithRpc("Test.StreamEmpty", {
683
+ grpc: { streaming: "server" }
684
+ });
685
+ // Set rawOutput to empty string to simulate no output
686
+ mockCallOperationRawOutput = "";
687
+
688
+ const grpcPath = "/webda.TestService/StreamEmpty";
689
+ const req = createMockReq(grpcPath, {});
690
+ const mock = createMockRes();
691
+
692
+ await service.handleGrpcRequest(req as any, mock.res);
693
+
694
+ // No data frames should be written (empty string filtered by split+filter)
695
+ assert.strictEqual(mock.written.length, 0);
696
+ assert.strictEqual(mock.trailers!["grpc-status"], "0");
697
+ }
698
+
699
+ @test
700
+ async serverStreamingHandlesOperationError() {
701
+ const service = setupServiceWithRpc("Test.StreamFail", {
702
+ grpc: { streaming: "server" }
703
+ });
704
+ const err: any = new Error("Stream failed");
705
+ err.getResponseCode = () => 400;
706
+ mockCallOperationError = err;
707
+
708
+ const grpcPath = "/webda.TestService/StreamFail";
709
+ const req = createMockReq(grpcPath, {});
710
+ const mock = createMockRes();
711
+
712
+ await service.handleGrpcRequest(req as any, mock.res);
713
+
714
+ assert.strictEqual(mock.trailers!["grpc-status"], String(GrpcStatus.INVALID_ARGUMENT));
715
+ }
716
+
717
+ @test
718
+ async clientStreamingCollectsMessagesAndResponds() {
719
+ const service = setupServiceWithRpc("Test.Upload", {
720
+ grpc: { streaming: "client" }
721
+ });
722
+
723
+ const grpcPath = "/webda.TestService/Upload";
724
+
725
+ // Create mock req that will emit multiple messages then end
726
+ const req = new EventEmitter() as any;
727
+ req.url = grpcPath;
728
+ req.headers = { "content-type": "application/grpc" };
729
+
730
+ const mock = createMockRes();
731
+
732
+ mockCallOperationResult = { count: 2, status: "received" };
733
+
734
+ const promise = service.handleGrpcRequest(req as any, mock.res);
735
+
736
+ // Emit two messages
737
+ process.nextTick(() => {
738
+ req.emit("data", createGrpcFrame({ item: "a" }));
739
+ req.emit("data", createGrpcFrame({ item: "b" }));
740
+ // End the stream
741
+ req.emit("end");
742
+ });
743
+
744
+ await promise;
745
+
746
+ // Should respond with a unary response (sendUnary)
747
+ assert.strictEqual(mock.written.length, 1);
748
+ const response = JSON.parse(mock.written[0].subarray(5).toString());
749
+ assert.deepStrictEqual(response, { count: 2, status: "received" });
750
+ assert.strictEqual(mock.trailers!["grpc-status"], "0");
751
+ }
752
+
753
+ @test
754
+ async bidiStreamingCollectsAndSendsResponse() {
755
+ const service = setupServiceWithRpc("Test.Chat", {
756
+ grpc: { streaming: "bidi" }
757
+ });
758
+
759
+ const grpcPath = "/webda.TestService/Chat";
760
+ const req = new EventEmitter() as any;
761
+ req.url = grpcPath;
762
+ req.headers = { "content-type": "application/grpc" };
763
+
764
+ const mock = createMockRes();
765
+
766
+ mockCallOperationResult = { reply: "ack" };
767
+
768
+ const promise = service.handleGrpcRequest(req as any, mock.res);
769
+
770
+ process.nextTick(() => {
771
+ req.emit("data", createGrpcFrame({ msg: "hello" }));
772
+ req.emit("end");
773
+ });
774
+
775
+ await promise;
776
+
777
+ // Bidi uses stream.send (not sendUnary), so no trailers with end
778
+ // The current implementation sends once then resolves
779
+ assert.strictEqual(mock.written.length, 1);
780
+ const response = JSON.parse(mock.written[0].subarray(5).toString());
781
+ assert.deepStrictEqual(response, { reply: "ack" });
782
+ }
783
+
784
+ @test
785
+ async clientStreamingHandlesOperationError() {
786
+ const service = setupServiceWithRpc("Test.UploadFail", {
787
+ grpc: { streaming: "client" }
788
+ });
789
+ const err: any = new Error("Upload failed");
790
+ err.getResponseCode = () => 401;
791
+ mockCallOperationError = err;
792
+
793
+ const grpcPath = "/webda.TestService/UploadFail";
794
+ const req = new EventEmitter() as any;
795
+ req.url = grpcPath;
796
+ req.headers = { "content-type": "application/grpc" };
797
+
798
+ const mock = createMockRes();
799
+
800
+ const promise = service.handleGrpcRequest(req as any, mock.res);
801
+
802
+ process.nextTick(() => {
803
+ req.emit("data", createGrpcFrame({ item: "x" }));
804
+ req.emit("end");
805
+ });
806
+
807
+ await promise;
808
+
809
+ assert.strictEqual(mock.trailers!["grpc-status"], String(GrpcStatus.UNAUTHENTICATED));
810
+ }
811
+
812
+ @test
813
+ async clientStreamingHandlesCancellation() {
814
+ const service = setupServiceWithRpc("Test.CancelUpload", {
815
+ grpc: { streaming: "client" }
816
+ });
817
+
818
+ const grpcPath = "/webda.TestService/CancelUpload";
819
+ const req = new EventEmitter() as any;
820
+ req.url = grpcPath;
821
+ req.headers = { "content-type": "application/grpc" };
822
+
823
+ const mock = createMockRes();
824
+
825
+ const promise = service.handleGrpcRequest(req as any, mock.res);
826
+
827
+ // Emit close (cancellation) instead of end
828
+ process.nextTick(() => {
829
+ req.emit("close");
830
+ });
831
+
832
+ await promise;
833
+
834
+ // Should resolve without writing data
835
+ assert.strictEqual(mock.written.length, 0);
836
+ }
837
+
838
+ @test
839
+ async unaryWithNoGrpcFieldUsesNoneStreaming() {
840
+ // Operation without grpc field should default to unary
841
+ const service = setupServiceWithRpc("Test.Simple", {
842
+ // no grpc field at all
843
+ });
844
+
845
+ mockCallOperationResult = { ok: true };
846
+
847
+ const grpcPath = "/webda.TestService/Simple";
848
+ const req = createMockReq(grpcPath, { query: "test" });
849
+ const mock = createMockRes();
850
+
851
+ await service.handleGrpcRequest(req as any, mock.res);
852
+
853
+ assert.strictEqual(mock.written.length, 1);
854
+ assert.strictEqual(mock.trailers!["grpc-status"], "0");
855
+ }
856
+
857
+ @test
858
+ async unaryWithNullOutputReturnsEmptyObject() {
859
+ const service = setupServiceWithRpc("Test.NoOutput", {});
860
+ mockCallOperationRawOutput = "";
861
+
862
+ const grpcPath = "/webda.TestService/NoOutput";
863
+ const req = createMockReq(grpcPath, {});
864
+ const mock = createMockRes();
865
+
866
+ await service.handleGrpcRequest(req as any, mock.res);
867
+
868
+ assert.strictEqual(mock.written.length, 1);
869
+ const response = JSON.parse(mock.written[0].subarray(5).toString());
870
+ assert.deepStrictEqual(response, {});
871
+ }
872
+ }
873
+
874
+ @suite
875
+ class GrpcServiceBuildRpcMapTest {
876
+ beforeEach() {
877
+ for (const key of Object.keys(mockOperations)) {
878
+ delete mockOperations[key];
879
+ }
880
+ }
881
+
882
+ @test
883
+ async buildRpcMapWithMatchingOperations() {
884
+ mockOperations["Post.Create"] = { service: "PostService", method: "create" };
885
+ mockOperations["Post.Get"] = { service: "PostService", method: "get" };
886
+
887
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ packageName: "webda" }));
888
+
889
+ // Set up definitions that match the operations
890
+ (service as any).definitions = {
891
+ "webda.PostService.Create": {
892
+ path: "/webda.PostService/Create",
893
+ requestSerialize: () => Buffer.alloc(0),
894
+ requestDeserialize: () => ({}),
895
+ responseSerialize: () => Buffer.alloc(0),
896
+ responseDeserialize: () => ({})
897
+ },
898
+ "webda.PostService.Get": {
899
+ path: "/webda.PostService/Get",
900
+ requestSerialize: () => Buffer.alloc(0),
901
+ requestDeserialize: () => ({}),
902
+ responseSerialize: () => Buffer.alloc(0),
903
+ responseDeserialize: () => ({})
904
+ }
905
+ };
906
+
907
+ (service as any).buildRpcMap();
908
+
909
+ assert.strictEqual((service as any).rpcToOperation.size, 2);
910
+ assert.strictEqual((service as any).rpcToOperation.get("/webda.PostService/Create"), "Post.Create");
911
+ assert.strictEqual((service as any).rpcToOperation.get("/webda.PostService/Get"), "Post.Get");
912
+ }
913
+
914
+ @test
915
+ async buildRpcMapSkipsNonObjectDefs() {
916
+ mockOperations["Post.Create"] = {};
917
+
918
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
919
+
920
+ (service as any).definitions = {
921
+ someString: "not an object",
922
+ someNull: null,
923
+ someNumber: 42
924
+ };
925
+
926
+ (service as any).buildRpcMap();
927
+
928
+ assert.strictEqual((service as any).rpcToOperation.size, 0);
929
+ }
930
+
931
+ @test
932
+ async buildRpcMapSkipsDefsWithoutPath() {
933
+ mockOperations["Post.Create"] = {};
934
+
935
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
936
+
937
+ (service as any).definitions = {
938
+ someObj: { requestSerialize: () => Buffer.alloc(0) } // no path
939
+ };
940
+
941
+ (service as any).buildRpcMap();
942
+
943
+ assert.strictEqual((service as any).rpcToOperation.size, 0);
944
+ }
945
+
946
+ @test
947
+ async buildRpcMapSkipsUnmatchedOperations() {
948
+ // Operations don't match the gRPC path names
949
+ mockOperations["User.Login"] = {};
950
+
951
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
952
+
953
+ (service as any).definitions = {
954
+ "webda.PostService.Create": {
955
+ path: "/webda.PostService/Create"
956
+ }
957
+ };
958
+
959
+ (service as any).buildRpcMap();
960
+
961
+ // Post.Create is not in mockOperations, so it should not be mapped
962
+ assert.strictEqual((service as any).rpcToOperation.size, 0);
963
+ }
964
+
965
+ @test
966
+ async buildRpcMapHandlesUndefinedDefinitions() {
967
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
968
+
969
+ // definitions is not set
970
+ (service as any).definitions = undefined;
971
+
972
+ // Should not throw
973
+ (service as any).buildRpcMap();
974
+
975
+ assert.strictEqual((service as any).rpcToOperation.size, 0);
976
+ }
977
+
978
+ @test
979
+ async buildRpcMapHandlesInvalidPathFormat() {
980
+ mockOperations["Post.Create"] = {};
981
+
982
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
983
+
984
+ (service as any).definitions = {
985
+ invalid: {
986
+ path: "/single-segment" // only one segment after split+filter
987
+ }
988
+ };
989
+
990
+ (service as any).buildRpcMap();
991
+
992
+ assert.strictEqual((service as any).rpcToOperation.size, 0);
993
+ }
994
+ }
995
+
996
+ @suite
997
+ class GrpcServiceInitTest {
998
+ @test
999
+ async initWithoutProtoFileLogsWarning() {
1000
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: "/nonexistent/path/app.proto" }));
1001
+
1002
+ // Should not throw
1003
+ await service.init();
1004
+ }
1005
+
1006
+ @test
1007
+ async initWithExistingProtoFileLoadsDefinitions() {
1008
+ const tmpDir = join(tmpdir(), `grpc-init-test-${Date.now()}`);
1009
+ mkdirSync(tmpDir, { recursive: true });
1010
+ const protoPath = join(tmpDir, "app.proto");
1011
+ writeFileSync(protoPath, 'syntax = "proto3"; package webda;');
1012
+
1013
+ try {
1014
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: protoPath }));
1015
+ // Should not throw — the mock proto-loader returns an empty object
1016
+ await service.init();
1017
+ } finally {
1018
+ rmSync(tmpDir, { recursive: true, force: true });
1019
+ }
1020
+ }
1021
+
1022
+ @test
1023
+ async initRegistersInterceptorOnEveryHttpServer() {
1024
+ // Two HttpServer-like services — each should receive the gRPC interceptor
1025
+ const interceptorsA: Function[] = [];
1026
+ const interceptorsB: Function[] = [];
1027
+ mockCoreServices = {
1028
+ HttpServer: { registerRequestInterceptor: (fn: Function) => interceptorsA.push(fn) },
1029
+ HttpServerH2c: { registerRequestInterceptor: (fn: Function) => interceptorsB.push(fn) },
1030
+ Plain: {} // no registerRequestInterceptor — must be skipped
1031
+ };
1032
+
1033
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: "/nonexistent/file.proto" }));
1034
+ await service.init();
1035
+
1036
+ assert.strictEqual(interceptorsA.length, 1, "HttpServer should get the interceptor");
1037
+ assert.strictEqual(interceptorsB.length, 1, "HttpServerH2c should get the interceptor");
1038
+
1039
+ // Interceptor short-circuits non-grpc content types
1040
+ const req = { headers: { "content-type": "text/html" } };
1041
+ const res = {};
1042
+ assert.strictEqual(interceptorsA[0](req, res), false);
1043
+
1044
+ // Interceptor claims application/grpc requests and runs handleGrpcRequest
1045
+ let handleCalled = false;
1046
+ (service as any).handleGrpcRequest = async () => {
1047
+ handleCalled = true;
1048
+ };
1049
+ const grpcReq = {
1050
+ url: "/webda.Missing/Method",
1051
+ headers: { "content-type": "application/grpc" }
1052
+ };
1053
+ const grpcRes = {};
1054
+ assert.strictEqual(interceptorsA[0](grpcReq, grpcRes), true);
1055
+ // handleGrpcRequest fires synchronously inside runWithInstanceStorage
1056
+ await new Promise(resolve => setImmediate(resolve));
1057
+ assert.ok(handleCalled, "Interceptor should dispatch to handleGrpcRequest");
1058
+
1059
+ mockCoreServices = {};
1060
+ }
1061
+
1062
+ @test
1063
+ async initLogsWarningWhenNoHttpServer() {
1064
+ // No services registered → init should log a warning but not throw.
1065
+ mockCoreServices = {};
1066
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: "/nonexistent/file.proto" }));
1067
+ await service.init();
1068
+ mockCoreServices = {};
1069
+ }
1070
+
1071
+ @test
1072
+ async initHandlesProtoLoadError() {
1073
+ const tmpDir = join(tmpdir(), `grpc-init-err-${Date.now()}`);
1074
+ mkdirSync(tmpDir, { recursive: true });
1075
+ const protoPath = join(tmpDir, "bad.proto");
1076
+ writeFileSync(protoPath, "invalid proto content");
1077
+
1078
+ // Make the mock throw on loadSync
1079
+ const protoLoader = await import("@grpc/proto-loader");
1080
+ vi.mocked(protoLoader.loadSync).mockImplementationOnce(() => {
1081
+ throw new Error("Parse error");
1082
+ });
1083
+
1084
+ try {
1085
+ const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: protoPath }));
1086
+ // Should not throw — error is caught and logged
1087
+ await service.init();
1088
+ } finally {
1089
+ rmSync(tmpDir, { recursive: true, force: true });
1090
+ }
1091
+ }
1092
+ }