@robosystems/client 0.2.47 → 0.2.48

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.
@@ -15,6 +15,8 @@ import {
15
15
  createSchedule,
16
16
  createStructure,
17
17
  deleteMappingAssociation,
18
+ getAccountRollups,
19
+ getClosingBookStructures,
18
20
  getLedgerAccountTree,
19
21
  getLedgerEntity,
20
22
  getLedgerSummary,
@@ -35,7 +37,9 @@ import {
35
37
  } from '../sdk/sdk.gen'
36
38
  import type {
37
39
  AccountListResponse,
40
+ AccountRollupsResponse,
38
41
  AccountTreeResponse,
42
+ ClosingBookStructuresResponse,
39
43
  ClosingEntryResponse,
40
44
  CreateClosingEntryRequest,
41
45
  CreateScheduleRequest,
@@ -750,4 +754,47 @@ export class LedgerClient {
750
754
  amount: data.amount,
751
755
  }
752
756
  }
757
+
758
+ // ── Closing Book ─────────────────────────────────────────────────────
759
+
760
+ /**
761
+ * Get all closing book structure categories for the sidebar.
762
+ * Returns statements, account rollups, schedules, trial balance,
763
+ * and period close grouped into categories.
764
+ */
765
+ async getClosingBookStructures(graphId: string): Promise<ClosingBookStructuresResponse> {
766
+ const response = await getClosingBookStructures({
767
+ path: { graph_id: graphId },
768
+ })
769
+
770
+ if (response.error) {
771
+ throw new Error(`Get closing book structures failed: ${JSON.stringify(response.error)}`)
772
+ }
773
+
774
+ return response.data as ClosingBookStructuresResponse
775
+ }
776
+
777
+ /**
778
+ * Get account rollups — CoA accounts grouped by reporting element with balances.
779
+ * Shows how company-specific accounts roll up to standardized reporting lines.
780
+ */
781
+ async getAccountRollups(
782
+ graphId: string,
783
+ options?: { mappingId?: string; startDate?: string; endDate?: string }
784
+ ): Promise<AccountRollupsResponse> {
785
+ const response = await getAccountRollups({
786
+ path: { graph_id: graphId },
787
+ query: {
788
+ mapping_id: options?.mappingId,
789
+ start_date: options?.startDate,
790
+ end_date: options?.endDate,
791
+ },
792
+ })
793
+
794
+ if (response.error) {
795
+ throw new Error(`Get account rollups failed: ${JSON.stringify(response.error)}`)
796
+ }
797
+
798
+ return response.data as AccountRollupsResponse
799
+ }
753
800
  }
@@ -1,5 +1,60 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { OperationClient } from './OperationClient'
3
+ import { EventType } from './SSEClient'
4
+
5
+ // Mock EventSource for SSE tests
6
+ class MockEventSource {
7
+ url: string
8
+ withCredentials: boolean
9
+ readyState: number = 0
10
+ onopen: ((event: any) => void) | null = null
11
+ onerror: ((event: any) => void) | null = null
12
+ onmessage: ((event: any) => void) | null = null
13
+ private eventListeners: Map<string, Set<(event: any) => void>> = new Map()
14
+
15
+ static CONNECTING = 0
16
+ static OPEN = 1
17
+ static CLOSED = 2
18
+
19
+ constructor(url: string, options?: { withCredentials?: boolean }) {
20
+ this.url = url
21
+ this.withCredentials = options?.withCredentials ?? false
22
+ setTimeout(() => {
23
+ this.readyState = MockEventSource.OPEN
24
+ if (this.onopen) {
25
+ this.onopen({ type: 'open' })
26
+ }
27
+ }, 0)
28
+ }
29
+
30
+ addEventListener(event: string, listener: (event: any) => void) {
31
+ if (!this.eventListeners.has(event)) {
32
+ this.eventListeners.set(event, new Set())
33
+ }
34
+ this.eventListeners.get(event)!.add(listener)
35
+ }
36
+
37
+ removeEventListener(event: string, listener: (event: any) => void) {
38
+ const listeners = this.eventListeners.get(event)
39
+ if (listeners) listeners.delete(listener)
40
+ }
41
+
42
+ dispatchEvent(event: any) {
43
+ const listeners = this.eventListeners.get(event.type)
44
+ if (listeners) listeners.forEach((l) => l(event))
45
+ return true
46
+ }
47
+
48
+ close() {
49
+ this.readyState = MockEventSource.CLOSED
50
+ }
51
+
52
+ simulateMessage(eventType: string, data: any) {
53
+ const event = { type: eventType, data: JSON.stringify(data), lastEventId: '' }
54
+ const listeners = this.eventListeners.get(eventType)
55
+ if (listeners) listeners.forEach((l) => l(event))
56
+ }
57
+ }
3
58
 
4
59
  // Helper to create proper mock Response objects
5
60
  function createMockResponse(data: any, options: { ok?: boolean; status?: number } = {}) {
@@ -123,6 +178,149 @@ describe('OperationClient', () => {
123
178
  })
124
179
  })
125
180
 
181
+ describe('monitorOperation', () => {
182
+ beforeEach(() => {
183
+ global.EventSource = MockEventSource as any
184
+ })
185
+
186
+ it('should resolve with success on OPERATION_COMPLETED', async () => {
187
+ const resultPromise = operationClient.monitorOperation('op_sse_1')
188
+
189
+ // Wait for SSE connection to establish
190
+ await new Promise((r) => setTimeout(r, 20))
191
+
192
+ // Get the SSE client and simulate completion
193
+ const sseClients = (operationClient as any).sseClients as Map<string, any>
194
+ const sseClient = sseClients.get('op_sse_1')
195
+ const eventSource = (sseClient as any).eventSource as MockEventSource
196
+ eventSource.simulateMessage(EventType.OPERATION_COMPLETED, {
197
+ result: { graph_id: 'graph_done' },
198
+ metadata: { took: 5000 },
199
+ })
200
+
201
+ const result = await resultPromise
202
+
203
+ expect(result.success).toBe(true)
204
+ expect(result.result).toEqual({ graph_id: 'graph_done' })
205
+ })
206
+
207
+ it('should resolve with error on OPERATION_ERROR', async () => {
208
+ const resultPromise = operationClient.monitorOperation('op_sse_err')
209
+
210
+ await new Promise((r) => setTimeout(r, 20))
211
+
212
+ const sseClients = (operationClient as any).sseClients as Map<string, any>
213
+ const sseClient = sseClients.get('op_sse_err')
214
+ const eventSource = (sseClient as any).eventSource as MockEventSource
215
+ eventSource.simulateMessage(EventType.OPERATION_ERROR, {
216
+ message: 'Processing failed',
217
+ })
218
+
219
+ const result = await resultPromise
220
+
221
+ expect(result.success).toBe(false)
222
+ expect(result.error).toBe('Processing failed')
223
+ })
224
+
225
+ it('should resolve with cancelled on OPERATION_CANCELLED', async () => {
226
+ const resultPromise = operationClient.monitorOperation('op_sse_cancel')
227
+
228
+ await new Promise((r) => setTimeout(r, 20))
229
+
230
+ const sseClients = (operationClient as any).sseClients as Map<string, any>
231
+ const sseClient = sseClients.get('op_sse_cancel')
232
+ const eventSource = (sseClient as any).eventSource as MockEventSource
233
+ eventSource.simulateMessage(EventType.OPERATION_CANCELLED, {})
234
+
235
+ const result = await resultPromise
236
+
237
+ expect(result.success).toBe(false)
238
+ expect(result.error).toBe('Operation cancelled')
239
+ })
240
+
241
+ it('should call onProgress callback', async () => {
242
+ const progressUpdates: any[] = []
243
+
244
+ const resultPromise = operationClient.monitorOperation('op_progress', {
245
+ onProgress: (p) => progressUpdates.push(p),
246
+ })
247
+
248
+ await new Promise((r) => setTimeout(r, 20))
249
+
250
+ const sseClients = (operationClient as any).sseClients as Map<string, any>
251
+ const sseClient = sseClients.get('op_progress')
252
+ const eventSource = (sseClient as any).eventSource as MockEventSource
253
+
254
+ // Send progress then completion
255
+ eventSource.simulateMessage(EventType.OPERATION_PROGRESS, {
256
+ message: 'Step 1 of 3',
257
+ progress_percent: 33,
258
+ })
259
+
260
+ eventSource.simulateMessage(EventType.OPERATION_COMPLETED, {
261
+ result: { done: true },
262
+ })
263
+
264
+ await resultPromise
265
+
266
+ expect(progressUpdates).toHaveLength(1)
267
+ expect(progressUpdates[0].message).toBe('Step 1 of 3')
268
+ expect(progressUpdates[0].progressPercent).toBe(33)
269
+ })
270
+
271
+ it('should timeout after specified duration', async () => {
272
+ const resultPromise = operationClient.monitorOperation('op_timeout', {
273
+ timeout: 100,
274
+ })
275
+
276
+ await expect(resultPromise).rejects.toThrow('Operation timeout after 100ms')
277
+ })
278
+ })
279
+
280
+ describe('waitForOperation', () => {
281
+ beforeEach(() => {
282
+ global.EventSource = MockEventSource as any
283
+ })
284
+
285
+ it('should return result on success', async () => {
286
+ const resultPromise = operationClient.waitForOperation('op_wait')
287
+
288
+ await new Promise((r) => setTimeout(r, 20))
289
+
290
+ const sseClients = (operationClient as any).sseClients as Map<string, any>
291
+ const sseClient = sseClients.get('op_wait')
292
+ const eventSource = (sseClient as any).eventSource as MockEventSource
293
+ eventSource.simulateMessage(EventType.OPERATION_COMPLETED, {
294
+ result: { data: 'done' },
295
+ })
296
+
297
+ const result = await resultPromise
298
+
299
+ expect(result).toEqual({ data: 'done' })
300
+ })
301
+
302
+ it('should throw on failure', async () => {
303
+ const resultPromise = operationClient.waitForOperation('op_wait_fail')
304
+
305
+ await new Promise((r) => setTimeout(r, 20))
306
+
307
+ const sseClients = (operationClient as any).sseClients as Map<string, any>
308
+ const sseClient = sseClients.get('op_wait_fail')
309
+ const eventSource = (sseClient as any).eventSource as MockEventSource
310
+ eventSource.simulateMessage(EventType.OPERATION_ERROR, {
311
+ message: 'Server error',
312
+ })
313
+
314
+ await expect(resultPromise).rejects.toThrow('Server error')
315
+ })
316
+
317
+ it('should timeout with specified duration', async () => {
318
+ await expect(operationClient.waitForOperation('op_wait_timeout', 100)).rejects.toThrow(
319
+ 'Operation timeout'
320
+ )
321
+ })
322
+ })
323
+
126
324
  describe('configuration', () => {
127
325
  it('should accept JWT token in config', () => {
128
326
  const client = new OperationClient({
@@ -237,10 +237,166 @@ describe('QueryClient', () => {
237
237
  })
238
238
  })
239
239
 
240
+ describe('executeQuery - stream mode with NDJSON', () => {
241
+ it('should parse NDJSON streaming response', async () => {
242
+ const ndjsonBody = [
243
+ '{"columns":["name","age"],"rows":[["Alice",30],["Bob",25]]}',
244
+ '{"rows":[["Charlie",35]]}',
245
+ '',
246
+ ].join('\n')
247
+
248
+ // Create a ReadableStream from the NDJSON string
249
+ const stream = new ReadableStream({
250
+ start(controller) {
251
+ controller.enqueue(new TextEncoder().encode(ndjsonBody))
252
+ controller.close()
253
+ },
254
+ })
255
+
256
+ const mockStreamResponse = {
257
+ data: undefined,
258
+ error: undefined,
259
+ response: {
260
+ ok: true,
261
+ status: 200,
262
+ headers: new Headers({ 'content-type': 'application/x-ndjson' }),
263
+ body: stream,
264
+ json: async () => ({}),
265
+ },
266
+ }
267
+
268
+ // Mock the SDK function via fetch
269
+ mockFetch.mockResolvedValueOnce({
270
+ ok: true,
271
+ status: 200,
272
+ headers: new Headers({ 'content-type': 'application/x-ndjson' }),
273
+ body: stream,
274
+ json: async () => mockStreamResponse,
275
+ text: async () => JSON.stringify(mockStreamResponse),
276
+ })
277
+
278
+ // We can't easily test the full stream mode through the SDK mock
279
+ // because the SDK parses the response. Test the NDJSON parser directly instead.
280
+ const parser = (queryClient as any).parseNDJSONResponse.bind(queryClient)
281
+
282
+ const ndjsonStream = new ReadableStream({
283
+ start(controller) {
284
+ controller.enqueue(
285
+ new TextEncoder().encode(
286
+ '{"columns":["name"],"rows":[["Alice"],["Bob"]]}\n{"rows":[["Charlie"]]}\n'
287
+ )
288
+ )
289
+ controller.close()
290
+ },
291
+ })
292
+
293
+ const mockResp = {
294
+ body: ndjsonStream,
295
+ headers: new Headers({ 'content-type': 'application/x-ndjson' }),
296
+ }
297
+
298
+ const result = await parser(mockResp, 'graph_1')
299
+
300
+ expect(result.columns).toEqual(['name'])
301
+ expect(result.data).toEqual([['Alice'], ['Bob'], ['Charlie']])
302
+ expect(result.row_count).toBe(3)
303
+ expect(result.graph_id).toBe('graph_1')
304
+ })
305
+
306
+ it('should handle NDJSON with data key instead of rows', async () => {
307
+ const parser = (queryClient as any).parseNDJSONResponse.bind(queryClient)
308
+
309
+ const stream = new ReadableStream({
310
+ start(controller) {
311
+ controller.enqueue(
312
+ new TextEncoder().encode(
313
+ '{"columns":["id"],"data":[{"id":1},{"id":2}],"execution_time_ms":50}\n'
314
+ )
315
+ )
316
+ controller.close()
317
+ },
318
+ })
319
+
320
+ const result = await parser({ body: stream }, 'graph_1')
321
+
322
+ expect(result.data).toEqual([{ id: 1 }, { id: 2 }])
323
+ expect(result.row_count).toBe(2)
324
+ expect(result.execution_time_ms).toBe(50)
325
+ })
326
+
327
+ it('should throw on NDJSON parse errors', async () => {
328
+ const parser = (queryClient as any).parseNDJSONResponse.bind(queryClient)
329
+
330
+ const stream = new ReadableStream({
331
+ start(controller) {
332
+ controller.enqueue(new TextEncoder().encode('not valid json\n'))
333
+ controller.close()
334
+ },
335
+ })
336
+
337
+ await expect(parser({ body: stream }, 'graph_1')).rejects.toThrow('NDJSON parsing failed')
338
+ })
339
+
340
+ it('should throw when body is not readable', async () => {
341
+ const parser = (queryClient as any).parseNDJSONResponse.bind(queryClient)
342
+
343
+ await expect(parser({ body: null }, 'graph_1')).rejects.toThrow(
344
+ 'Response body is not readable'
345
+ )
346
+ })
347
+
348
+ it('should track max execution_time_ms across chunks', async () => {
349
+ const parser = (queryClient as any).parseNDJSONResponse.bind(queryClient)
350
+
351
+ const stream = new ReadableStream({
352
+ start(controller) {
353
+ controller.enqueue(
354
+ new TextEncoder().encode(
355
+ '{"columns":["x"],"rows":[[1]],"execution_time_ms":10}\n' +
356
+ '{"rows":[[2]],"execution_time_ms":50}\n' +
357
+ '{"rows":[[3]],"execution_time_ms":25}\n'
358
+ )
359
+ )
360
+ controller.close()
361
+ },
362
+ })
363
+
364
+ const result = await parser({ body: stream }, 'graph_1')
365
+
366
+ expect(result.execution_time_ms).toBe(50) // max across chunks
367
+ expect(result.row_count).toBe(3)
368
+ })
369
+ })
370
+
371
+ describe('streamQuery', () => {
372
+ it('should yield all data items when result is non-streaming', async () => {
373
+ mockFetch.mockResolvedValueOnce(
374
+ createMockResponse({
375
+ data: [{ id: 1 }, { id: 2 }, { id: 3 }],
376
+ columns: ['id'],
377
+ row_count: 3,
378
+ execution_time_ms: 10,
379
+ })
380
+ )
381
+
382
+ const items: any[] = []
383
+ for await (const item of queryClient.streamQuery('graph_1', 'MATCH (n) RETURN n')) {
384
+ items.push(item)
385
+ }
386
+
387
+ expect(items).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }])
388
+ })
389
+ })
390
+
240
391
  describe('close', () => {
241
392
  it('should close without error when no SSE client exists', () => {
242
393
  expect(() => queryClient.close()).not.toThrow()
243
394
  })
395
+
396
+ it('should be safe to call multiple times', () => {
397
+ queryClient.close()
398
+ expect(() => queryClient.close()).not.toThrow()
399
+ })
244
400
  })
245
401
 
246
402
  describe('QueuedQueryError', () => {
@@ -259,5 +415,18 @@ describe('QueryClient', () => {
259
415
  expect(error.name).toBe('QueuedQueryError')
260
416
  expect(error.queueInfo).toEqual(queueInfo)
261
417
  })
418
+
419
+ it('should be instanceof Error', () => {
420
+ const error = new QueuedQueryError({
421
+ status: 'queued',
422
+ operation_id: 'op_1',
423
+ queue_position: 1,
424
+ estimated_wait_seconds: 5,
425
+ message: 'Queued',
426
+ })
427
+
428
+ expect(error).toBeInstanceOf(Error)
429
+ expect(error).toBeInstanceOf(QueuedQueryError)
430
+ })
262
431
  })
263
432
  })