From fb208cc1389c28cb95fe87ecaf7c6f3402c46e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Mon, 1 Jun 2026 12:18:03 +0200 Subject: [PATCH 01/13] test(core-acs-reader): add cache test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- core/acs-reader/src/__test__/reader.test.ts | 6 + core/acs-reader/src/__test__/service.test.ts | 6 + .../src/cache/__test__/cache.test.ts | 483 ++++++++++++++++++ .../src/cache/__test__/collection.test.ts | 6 + core/acs-reader/vitest.config.ts | 1 - 5 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 core/acs-reader/src/__test__/reader.test.ts create mode 100644 core/acs-reader/src/__test__/service.test.ts create mode 100644 core/acs-reader/src/cache/__test__/cache.test.ts create mode 100644 core/acs-reader/src/cache/__test__/collection.test.ts diff --git a/core/acs-reader/src/__test__/reader.test.ts b/core/acs-reader/src/__test__/reader.test.ts new file mode 100644 index 000000000..a13465f6d --- /dev/null +++ b/core/acs-reader/src/__test__/reader.test.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe } from 'vitest' + +describe('reader', () => {}) diff --git a/core/acs-reader/src/__test__/service.test.ts b/core/acs-reader/src/__test__/service.test.ts new file mode 100644 index 000000000..9babcb18a --- /dev/null +++ b/core/acs-reader/src/__test__/service.test.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe } from 'vitest' + +describe('service', () => {}) diff --git a/core/acs-reader/src/cache/__test__/cache.test.ts b/core/acs-reader/src/cache/__test__/cache.test.ts new file mode 100644 index 000000000..5705038d8 --- /dev/null +++ b/core/acs-reader/src/cache/__test__/cache.test.ts @@ -0,0 +1,483 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ACSCache } from '../cache' + +const { getActiveContracts, MockACSService } = vi.hoisted(() => { + const getActiveContracts = vi.fn() + + const MockACSService = vi.fn( + class { + getActiveContracts = getActiveContracts + } + ) + + return { getActiveContracts, MockACSService } +}) + +vi.mock('../../service.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + AcsService: MockACSService, + } +}) + +const ledgerProvider = vi.hoisted(() => ({ + request: vi.fn(), +})) + +describe('cache', () => { + let cache: ACSCache + + beforeEach(() => { + vi.clearAllMocks() + + getActiveContracts.mockReturnValue([ + { + workflowId: 'id1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'initial-contract-1', + templateId: 'template1', + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + { + workflowId: 'id2', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'initial-contract-2', + templateId: 'template2', + }, + synchronizerId: 'sync2', + reassignmentCounter: 0, + }, + }, + }, + ]) + + ledgerProvider.request.mockResolvedValue([]) + + cache = new ACSCache(ledgerProvider) + }) + + it('should call ACSService upon init', () => { + expect(MockACSService).toHaveBeenCalledOnce() + }) + + describe('update', () => { + it('should init state when cache is empty', async () => { + const updateOptions = { + offset: 100, + } + await cache.update(updateOptions) + expect(getActiveContracts).toHaveBeenCalledExactlyOnceWith( + updateOptions + ) + }) + + it('should init state when initial.offset > options.offset', async () => { + await cache.update({ offset: 200 }) + + // Second update with lower offset should trigger initState again + await cache.update({ offset: 100 }) + + expect(getActiveContracts).toHaveBeenCalledWith({ offset: 100 }) + }) + + it('should fetch updates with correct parameters', async () => { + const mockUpdates = [ + { + update: { + OffsetCheckpoint: { + value: { offset: 150 }, + }, + }, + }, + ] + ledgerProvider.request.mockResolvedValueOnce(mockUpdates) + + await cache.update({ offset: 100 }) + + await cache.update({ offset: 200 }) + + expect(ledgerProvider.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'ledgerApi', + params: expect.objectContaining({ + resource: '/v2/updates', + requestMethod: 'post', + body: expect.objectContaining({ + beginExclusive: 150, + endInclusive: 200, + }), + }), + }) + ) + }) + + it('should update offset and concatenate events when new events are found', async () => { + const mockUpdates = [ + { + update: { + Transaction: { + value: { + offset: 150, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract1', + templateId: { value: 'template1' }, + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: [], + observers: [], + }, + }, + ], + }, + }, + }, + }, + ] + ledgerProvider.request.mockResolvedValue(mockUpdates) + + await cache.update({ offset: 100 }) + + // Verify the update was processed without calling calculateAt + expect(ledgerProvider.request).toHaveBeenCalled() + }) + + it('should recursively call update when reaching maxUpdatesToFetch', async () => { + // Create exactly 100 updates (the maxUpdatesToFetch limit) + const mockUpdates = Array.from({ length: 100 }, (_, i) => ({ + update: { + OffsetCheckpoint: { + value: { offset: 100 + i }, + }, + }, + })) + + ledgerProvider.request.mockResolvedValueOnce(mockUpdates) + + await cache.update({ offset: 100 }) + + // Should have made multiple requests due to recursive call + expect(ledgerProvider.request).toHaveBeenCalledTimes(2) + }) + + it('should handle archived events correctly', async () => { + const mockUpdates = [ + { + update: { + Transaction: { + value: { + offset: 150, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + ArchivedEvent: { + contractId: 'initial-contract-1', + templateId: { value: 'template1' }, + }, + }, + ], + }, + }, + }, + }, + ] + ledgerProvider.request.mockResolvedValue(mockUpdates) + + await cache.update({ offset: 100 }) + + await cache.update({ offset: 200 }) + + // Should process archived events without errors + expect(ledgerProvider.request).toHaveBeenCalledTimes(2) + }) + }) + + describe('calculateAt', () => { + it('should throw error when offset is smaller than initial offset', async () => { + await cache.update({ offset: 200 }) + + expect(() => cache.calculateAt(100)).toThrow() + }) + + it('should return initial contracts when no updates have been applied', async () => { + await cache.update({ offset: 100 }) + + const result = cache.calculateAt(100) + + const expectedResult = getActiveContracts() + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject(expectedResult[0]) + expect(result[1]).toMatchObject(expectedResult[1]) + }) + + it('should include new created contracts in the result', async () => { + await cache.update({ offset: 100 }) + + const mockUpdates = [ + { + update: { + Transaction: { + value: { + offset: 150, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'new-contract-1', + templateId: { value: 'template3' }, + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: [], + observers: [], + }, + }, + ], + }, + }, + }, + }, + ] + ledgerProvider.request.mockResolvedValueOnce(mockUpdates) + + await cache.update({ offset: 200 }) + + const result = cache.calculateAt(200) + + expect(result).toHaveLength(3) + expect( + result.some( + (c) => + c.contractEntry && + 'JsActiveContract' in c.contractEntry && + c.contractEntry.JsActiveContract.createdEvent + .contractId === 'new-contract-1' + ) + ) + }) + + it('should handle multiple created and archived events correctly', async () => { + await cache.update({ offset: 100 }) + + const mockUpdates = [ + { + update: { + Transaction: { + value: { + offset: 150, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'new-contract-1', + templateId: { value: 'template3' }, + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: [], + observers: [], + }, + }, + ], + }, + }, + }, + }, + { + update: { + Transaction: { + value: { + offset: 160, + workflowId: 'wf2', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'new-contract-2', + templateId: { value: 'template4' }, + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: [], + observers: [], + }, + }, + ], + }, + }, + }, + }, + { + update: { + Transaction: { + value: { + offset: 170, + workflowId: 'wf3', + synchronizerId: 'sync1', + events: [ + { + ArchivedEvent: { + contractId: 'new-contract-1', + templateId: { value: 'template3' }, + }, + }, + ], + }, + }, + }, + }, + ] + ledgerProvider.request.mockResolvedValueOnce(mockUpdates) + + await cache.update({ offset: 200 }) + + const result = cache.calculateAt(200) + + // Should have: initial-contract-1, initial-contract-2, new-contract-2 + // new-contract-1 was created and then archived + expect(result).toHaveLength(3) + expect( + result.some( + (c) => + c.contractEntry && + 'JsActiveContract' in c.contractEntry && + c.contractEntry.JsActiveContract.createdEvent + .contractId === 'new-contract-1' + ) + ).toBe(false) + expect( + result.some( + (c) => + c.contractEntry && + 'JsActiveContract' in c.contractEntry && + c.contractEntry.JsActiveContract.createdEvent + .contractId === 'new-contract-2' + ) + ).toBe(true) + }) + + it('should work correctly when called multiple times with different offsets', async () => { + await cache.update({ offset: 100 }) + + const mockUpdates = [ + { + update: { + Transaction: { + value: { + offset: 150, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract-150', + templateId: { value: 'template3' }, + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: [], + observers: [], + }, + }, + ], + }, + }, + }, + }, + { + update: { + Transaction: { + value: { + offset: 200, + workflowId: 'wf2', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract-200', + templateId: { value: 'template4' }, + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: [], + observers: [], + }, + }, + ], + }, + }, + }, + }, + ] + ledgerProvider.request.mockResolvedValueOnce(mockUpdates) + + await cache.update({ offset: 250 }) + + const resultAt150 = cache.calculateAt(150) + expect(resultAt150).toHaveLength(3) // 2 initial + 1 at 150 + + const resultAt180 = cache.calculateAt(180) + expect(resultAt180).toHaveLength(3) // 2 initial + 1 at 150 + + const resultAt250 = cache.calculateAt(250) + expect(resultAt250).toHaveLength(4) // 2 initial + 1 at 150 + 1 at 200 + }) + + it('should handle empty contract entries gracefully', async () => { + const mockUpdates = [ + { + update: { + Transaction: { + value: { + offset: 150, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'new-contract', + templateId: { value: 'template3' }, + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: [], + observers: [], + }, + }, + ], + }, + }, + }, + }, + ] + ledgerProvider.request.mockResolvedValue(mockUpdates) + + await cache.update({ offset: 200 }) + + const result = cache.calculateAt(200) + + // Should filter out any entries without contractEntry + expect(result.every(Boolean)).toBe(true) + }) + }) +}) diff --git a/core/acs-reader/src/cache/__test__/collection.test.ts b/core/acs-reader/src/cache/__test__/collection.test.ts new file mode 100644 index 000000000..aa332e7ee --- /dev/null +++ b/core/acs-reader/src/cache/__test__/collection.test.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe } from 'vitest' + +describe('cache collection', () => {}) diff --git a/core/acs-reader/vitest.config.ts b/core/acs-reader/vitest.config.ts index 6d6980ae1..6d8591613 100644 --- a/core/acs-reader/vitest.config.ts +++ b/core/acs-reader/vitest.config.ts @@ -34,7 +34,6 @@ export default defineConfig({ browser: { enabled: true, provider: playwright({ - trace: 'off', screenshot: 'off', video: 'off', }), From 35dde8b6d7235d63892afe1af34b6e602595d103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Mon, 1 Jun 2026 12:37:34 +0200 Subject: [PATCH 02/13] test(core-acs-reader): add collection tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- .../src/cache/__test__/collection.test.ts | 259 +++++++++++++++++- 1 file changed, 257 insertions(+), 2 deletions(-) diff --git a/core/acs-reader/src/cache/__test__/collection.test.ts b/core/acs-reader/src/cache/__test__/collection.test.ts index aa332e7ee..a6998f91c 100644 --- a/core/acs-reader/src/cache/__test__/collection.test.ts +++ b/core/acs-reader/src/cache/__test__/collection.test.ts @@ -1,6 +1,261 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ACSCacheCollection } from '../collection' -describe('cache collection', () => {}) +const { mockCache, MockACSCache } = vi.hoisted(() => { + const update = vi.fn() + const calculateAt = vi.fn() + + const mockCache = { + update, + calculateAt, + } + + const MockACSCache = vi.fn( + class { + update = update + calculateAt = calculateAt + } + ) + + return { mockCache, MockACSCache } +}) + +vi.mock('../cache', () => ({ + ACSCache: MockACSCache, +})) + +const ledgerProvider = vi.hoisted(() => ({ + request: vi.fn(), +})) + +describe('cache collection', () => { + let collection: ACSCacheCollection + + beforeEach(() => { + vi.clearAllMocks() + mockCache.update.mockResolvedValue(undefined) + mockCache.calculateAt.mockReturnValue([ + { + workflowId: 'test-workflow', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-1', + templateId: 'template1', + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ]) + + collection = new ACSCacheCollection(ledgerProvider) + }) + + it('should create cache collection with default options', () => { + expect(collection).toBeDefined() + }) + + it('should create cache collection with custom options', () => { + const customCollection = new ACSCacheCollection(ledgerProvider, { + maxSize: 50, + entryExpirationTimeInMS: 5 * 60 * 1000, + }) + expect(customCollection).toBeDefined() + }) + + describe('readFromCache', () => { + it('should read from cache with single party and template', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + const result = await collection.readFromCache(options) + + expect(MockACSCache).toHaveBeenCalledWith(ledgerProvider) + expect(mockCache.update).toHaveBeenCalledWith(options) + expect(mockCache.calculateAt).toHaveBeenCalledWith(100) + expect(result).toHaveLength(1) + }) + + it('should read from cache with multiple parties and templates', async () => { + const options = { + offset: 100, + parties: ['party1', 'party2'], + templateIds: ['template1', 'template2'], + } + + const result = await collection.readFromCache(options) + + // Should create 4 cache instances (2 parties × 2 templates) + expect(MockACSCache).toHaveBeenCalledTimes(4) + expect(mockCache.update).toHaveBeenCalledTimes(4) + expect(mockCache.calculateAt).toHaveBeenCalledTimes(4) + expect(result).toHaveLength(4) + }) + + it('should read from cache with parties and interfaces', async () => { + const options = { + offset: 100, + parties: ['party1'], + interfaceIds: ['interface1', 'interface2'], + } + + const result = await collection.readFromCache(options) + + // Should create 2 cache instances (1 party × 2 interfaces) + expect(MockACSCache).toHaveBeenCalledTimes(2) + expect(mockCache.update).toHaveBeenCalledTimes(2) + expect(result).toHaveLength(2) + }) + + it('should read from cache with parties, templates, and interfaces', async () => { + const options = { + offset: 150, + parties: ['party1'], + templateIds: ['template1'], + interfaceIds: ['interface1'], + } + + const result = await collection.readFromCache(options) + + // Should create 2 cache instances (1 for interface, 1 for template) + expect(MockACSCache).toHaveBeenCalledTimes(2) + expect(mockCache.update).toHaveBeenCalledTimes(2) + expect(mockCache.calculateAt).toHaveBeenCalledWith(150) + expect(result).toHaveLength(2) + }) + + it('should reuse existing cache for same key', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + await collection.readFromCache(options) + await collection.readFromCache(options) + + // Should only create cache once + expect(MockACSCache).toHaveBeenCalledOnce() + // But should update and calculate twice + expect(mockCache.update).toHaveBeenCalledTimes(2) + expect(mockCache.calculateAt).toHaveBeenCalledTimes(2) + }) + + it('should create different caches for different keys', async () => { + await collection.readFromCache({ + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + }) + + await collection.readFromCache({ + offset: 100, + parties: ['party2'], + templateIds: ['template1'], + }) + + // Should create two different caches + expect(MockACSCache).toHaveBeenCalledTimes(2) + }) + + it('should flatten results from multiple queries', async () => { + mockCache.calculateAt.mockReturnValue([ + { + contractEntry: { + JsActiveContract: { + createdEvent: { contractId: 'c1' }, + }, + }, + }, + { + contractEntry: { + JsActiveContract: { + createdEvent: { contractId: 'c2' }, + }, + }, + }, + ]) + + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1', 'template2'], + } + + const result = await collection.readFromCache(options) + + // 2 templates × 2 contracts per template = 4 total contracts + expect(result).toHaveLength(4) + }) + + it('should query all keys in parallel', async () => { + const updatePromises: Array<() => void> = [] + mockCache.update.mockImplementation( + () => + new Promise((resolve) => { + updatePromises.push(() => resolve(undefined)) + }) + ) + + const options = { + offset: 100, + parties: ['party1', 'party2'], + templateIds: ['template1', 'template2'], + } + + const resultPromise = collection.readFromCache(options) + + // Wait a bit to ensure all updates are called + await new Promise((resolve) => setTimeout(resolve, 10)) + + // All 4 updates should be called before any resolves + expect(mockCache.update).toHaveBeenCalledTimes(4) + expect(mockCache.calculateAt).not.toHaveBeenCalled() + + // Resolve all promises + updatePromises.forEach((resolve) => resolve()) + + await resultPromise + + expect(mockCache.calculateAt).toHaveBeenCalledTimes(4) + }) + + it('should handle cache update errors', async () => { + mockCache.update.mockRejectedValue(new Error('Update failed')) + + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + await expect(collection.readFromCache(options)).rejects.toThrow( + 'Update failed' + ) + }) + + it('should handle calculateAt errors', async () => { + mockCache.calculateAt.mockImplementation(() => { + throw new Error('Calculate failed') + }) + + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + await expect(collection.readFromCache(options)).rejects.toThrow( + 'Calculate failed' + ) + }) + }) +}) From 30f4e4f437604ecb95c538462b5c91a44b62132b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Mon, 1 Jun 2026 13:26:15 +0200 Subject: [PATCH 03/13] test(core-acs-reader): add reader tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- core/acs-reader/src/__test__/reader.test.ts | 464 +++++++++++++++++++- 1 file changed, 462 insertions(+), 2 deletions(-) diff --git a/core/acs-reader/src/__test__/reader.test.ts b/core/acs-reader/src/__test__/reader.test.ts index a13465f6d..d1ceba781 100644 --- a/core/acs-reader/src/__test__/reader.test.ts +++ b/core/acs-reader/src/__test__/reader.test.ts @@ -1,6 +1,466 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ACSReader } from '../reader' -describe('reader', () => {}) +const { mockCacheCollection, MockACSCacheCollection } = vi.hoisted(() => { + const readFromCache = vi.fn() + + const mockCacheCollection = { + readFromCache, + } + + const MockACSCacheCollection = vi.fn( + class { + readFromCache = readFromCache + } + ) + + return { mockCacheCollection, MockACSCacheCollection } +}) + +const { mockService, MockAcsService } = vi.hoisted(() => { + const getActiveContracts = vi.fn() + + const mockService = { + getActiveContracts, + } + + const MockAcsService = vi.fn( + class { + getActiveContracts = getActiveContracts + } + ) + + return { mockService, MockAcsService } +}) + +vi.mock('../cache/collection', () => ({ + ACSCacheCollection: MockACSCacheCollection, +})) + +vi.mock('../service', () => ({ + AcsService: MockAcsService, +})) + +const ledgerProvider = vi.hoisted(() => ({ + request: vi.fn(), +})) + +describe('reader', () => { + let reader: ACSReader + + const mockActiveContracts = [ + { + workflowId: 'wf1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-1', + templateId: 'template1', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + { + workflowId: 'wf2', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-2', + templateId: 'template2', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party2'], + observers: [], + }, + synchronizerId: 'sync2', + reassignmentCounter: 0, + }, + }, + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + + mockService.getActiveContracts.mockResolvedValue(mockActiveContracts) + mockCacheCollection.readFromCache.mockResolvedValue(mockActiveContracts) + ledgerProvider.request.mockResolvedValue({ offset: 1000 }) + + reader = new ACSReader(ledgerProvider) + }) + + it('should initialize cache collection and service', () => { + expect(MockACSCacheCollection).toHaveBeenCalledWith( + ledgerProvider, + undefined + ) + expect(MockAcsService).toHaveBeenCalledWith(ledgerProvider) + }) + + it('should initialize with custom cache options', () => { + const cacheOptions = { + maxSize: 50, + entryExpirationTimeInMS: 5 * 60 * 1000, + } + + new ACSReader(ledgerProvider, cacheOptions) + + expect(MockACSCacheCollection).toHaveBeenCalledWith( + ledgerProvider, + cacheOptions + ) + }) + + describe('raw.read', () => { + it('should read active contracts directly without cache', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + const result = await reader.raw.read(options) + + expect(mockService.getActiveContracts).toHaveBeenCalledWith(options) + expect(result).toEqual(mockActiveContracts) + expect(mockCacheCollection.readFromCache).not.toHaveBeenCalled() + }) + + it('should resolve offset when not provided', async () => { + ledgerProvider.request.mockResolvedValue({ offset: 500 }) + + const options = { + parties: ['party1'], + templateIds: ['template1'], + } + + await reader.raw.read(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/state/ledger-end', + requestMethod: 'get', + }, + }) + + expect(mockService.getActiveContracts).toHaveBeenCalledWith({ + ...options, + offset: 500, + }) + }) + + it('should handle empty results', async () => { + mockService.getActiveContracts.mockResolvedValue([]) + + const result = await reader.raw.read({ + offset: 100, + parties: ['party1'], + }) + + expect(result).toEqual([]) + }) + }) + + describe('raw.readJsContracts', () => { + it('should read and transform to JS contracts', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + const result = await reader.raw.readJsContracts(options) + + expect(mockService.getActiveContracts).toHaveBeenCalledWith(options) + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + contractId: 'contract-1', + templateId: 'template1', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + synchronizerId: 'sync1', + }) + }) + + it('should filter out contracts without JsActiveContract', async () => { + const mixedContracts = [ + ...mockActiveContracts, + { + workflowId: 'wf3', + contractEntry: null, + }, + { + workflowId: 'wf4', + contractEntry: { + OtherType: {}, + }, + }, + ] + + mockService.getActiveContracts.mockResolvedValue(mixedContracts) + + const result = await reader.raw.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + + expect(result).toHaveLength(2) + }) + + it('should return empty array when no contracts', async () => { + mockService.getActiveContracts.mockResolvedValue([]) + + const result = await reader.raw.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + + expect(result).toEqual([]) + }) + }) + + describe('read', () => { + it('should read active contracts from cache', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + const result = await reader.read(options) + + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + options + ) + expect(result).toEqual(mockActiveContracts) + expect(mockService.getActiveContracts).not.toHaveBeenCalled() + }) + + it('should resolve offset when not provided', async () => { + ledgerProvider.request.mockResolvedValue({ offset: 750 }) + + const options = { + parties: ['party1'], + templateIds: ['template1'], + } + + await reader.read(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/state/ledger-end', + requestMethod: 'get', + }, + }) + + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith({ + ...options, + offset: 750, + }) + }) + + it('should handle empty cache results', async () => { + mockCacheCollection.readFromCache.mockResolvedValue([]) + + const result = await reader.read({ + offset: 100, + parties: ['party1'], + }) + + expect(result).toEqual([]) + }) + + it('should work with multiple parties and templates', async () => { + const options = { + offset: 200, + parties: ['party1', 'party2'], + templateIds: ['template1', 'template2'], + } + + await reader.read(options) + + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + options + ) + }) + + it('should work with interface IDs', async () => { + const options = { + offset: 200, + parties: ['party1'], + interfaceIds: ['interface1'], + } + + await reader.read(options) + + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + options + ) + }) + }) + + describe('readJsContracts', () => { + it('should read from cache and transform to JS contracts', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + const result = await reader.readJsContracts(options) + + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + options + ) + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + contractId: 'contract-1', + templateId: 'template1', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + synchronizerId: 'sync1', + }) + expect(result[1]).toEqual({ + contractId: 'contract-2', + templateId: 'template2', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party2'], + observers: [], + synchronizerId: 'sync2', + }) + }) + + it('should filter out contracts without JsActiveContract', async () => { + const mixedContracts = [ + mockActiveContracts[0], + { + workflowId: 'wf3', + contractEntry: null, + }, + mockActiveContracts[1], + { + workflowId: 'wf4', + contractEntry: { + OtherType: {}, + }, + }, + ] + + mockCacheCollection.readFromCache.mockResolvedValue(mixedContracts) + + const result = await reader.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + + expect(result).toHaveLength(2) + expect(result[0].contractId).toBe('contract-1') + expect(result[1].contractId).toBe('contract-2') + }) + + it('should return empty array when cache is empty', async () => { + mockCacheCollection.readFromCache.mockResolvedValue([]) + + const result = await reader.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + + expect(result).toEqual([]) + }) + + it('should preserve synchronizerId in output', async () => { + const result = await reader.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + + expect(result[0].synchronizerId).toBe('sync1') + expect(result[1].synchronizerId).toBe('sync2') + }) + + it('should resolve offset before reading from cache', async () => { + ledgerProvider.request.mockResolvedValue({ offset: 999 }) + + await reader.readJsContracts({ + parties: ['party1'], + templateIds: ['template1'], + }) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/state/ledger-end', + requestMethod: 'get', + }, + }) + + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith({ + parties: ['party1'], + templateIds: ['template1'], + offset: 999, + }) + }) + }) + + describe('error handling', () => { + it('should propagate errors from service', async () => { + const error = new Error('Service error') + mockService.getActiveContracts.mockRejectedValue(error) + + await expect( + reader.raw.read({ offset: 100, parties: ['party1'] }) + ).rejects.toThrow('Service error') + }) + + it('should propagate errors from cache', async () => { + const error = new Error('Cache error') + mockCacheCollection.readFromCache.mockRejectedValue(error) + + await expect( + reader.read({ offset: 100, parties: ['party1'] }) + ).rejects.toThrow('Cache error') + }) + + it('should propagate errors from ledger-end request', async () => { + const error = new Error('Ledger error') + ledgerProvider.request.mockRejectedValue(error) + + await expect(reader.read({ parties: ['party1'] })).rejects.toThrow( + 'Ledger error' + ) + }) + + it('should handle null offset from ledger-end gracefully', async () => { + ledgerProvider.request.mockResolvedValue({ offset: null }) + + await reader.read({ parties: ['party1'] }) + + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith({ + parties: ['party1'], + offset: null, + }) + }) + }) +}) From 2eb3fea8c803c70a01a7c84149a483ca3de00480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Mon, 1 Jun 2026 14:36:03 +0200 Subject: [PATCH 04/13] test(core-acs-reader): add test for service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- core/acs-reader/src/__test__/service.test.ts | 948 ++++++++++++++++++- 1 file changed, 946 insertions(+), 2 deletions(-) diff --git a/core/acs-reader/src/__test__/service.test.ts b/core/acs-reader/src/__test__/service.test.ts index 9babcb18a..16ed8cfa1 100644 --- a/core/acs-reader/src/__test__/service.test.ts +++ b/core/acs-reader/src/__test__/service.test.ts @@ -1,6 +1,950 @@ // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AcsService, + awaitCompletion, + buildActiveContractFilter, + promiseWithTimeout, +} from '../service' -describe('service', () => {}) +const ledgerProvider = vi.hoisted(() => ({ + request: vi.fn(), +})) + +describe('service', () => { + let service: AcsService + + const mockActiveContracts = [ + { + workflowId: 'wf1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-1', + templateId: 'template1', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + ledgerProvider.request.mockResolvedValue(mockActiveContracts) + service = new AcsService(ledgerProvider) + }) + + describe('getActiveContracts', () => { + it('should fetch active contracts with basic options', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + const result = await service.getActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/state/active-contracts', + requestMethod: 'post', + body: expect.objectContaining({ + activeAtOffset: 100, + verbose: false, + }), + query: {}, + }, + }) + expect(result).toEqual(mockActiveContracts) + }) + + it('should include limit in query when provided', async () => { + const options = { + offset: 100, + parties: ['party1'], + limit: 50, + } + + await service.getActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/state/active-contracts', + requestMethod: 'post', + body: expect.anything(), + query: { limit: 50 }, + }, + }) + }) + + it('should use default limit of 200 when continueUntilCompletion is true', async () => { + ledgerProvider.request.mockResolvedValueOnce({ offset: 1000 }) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + } + + await service.getActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'ledgerApi', + params: expect.objectContaining({ + resource: '/v2/updates', + query: { limit: 200 }, + }), + }) + ) + }) + + it('should use custom limit when continueUntilCompletion is true', async () => { + ledgerProvider.request.mockResolvedValueOnce({ offset: 1000 }) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + limit: 150, + } + + await service.getActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'ledgerApi', + params: expect.objectContaining({ + resource: '/v2/updates', + query: { limit: 150 }, + }), + }) + ) + }) + + it('should scan whole ledger when continueUntilCompletion is true', async () => { + ledgerProvider.request + .mockResolvedValueOnce({ offset: 200 }) + .mockResolvedValueOnce([ + { + update: { + Transaction: { + value: { + offset: 200, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract-1', + templateId: { + value: 'template1', + }, + contractKey: null, + createArgument: { + owner: 'party1', + }, + createdAt: + '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + }, + }, + ], + }, + }, + }, + }, + ]) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + filterByParty: true, + } + + const result = await service.getActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledTimes(2) + expect(result).toHaveLength(1) + expect(result[0].contractEntry).toHaveProperty('JsActiveContract') + }) + + it('should handle archived events when scanning ledger', async () => { + ledgerProvider.request + .mockResolvedValueOnce({ offset: 300 }) + .mockResolvedValueOnce([ + { + update: { + Transaction: { + value: { + offset: 200, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract-1', + templateId: { + value: 'template1', + }, + contractKey: null, + createArgument: { + owner: 'party1', + }, + createdAt: + '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + }, + }, + ], + }, + }, + }, + }, + { + update: { + Transaction: { + value: { + offset: 300, + workflowId: 'wf2', + synchronizerId: 'sync1', + events: [ + { + ArchivedEvent: { + contractId: 'contract-1', + templateId: { + value: 'template1', + }, + }, + }, + ], + }, + }, + }, + }, + ]) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + filterByParty: true, + } + + const result = await service.getActiveContracts(options) + + expect(result).toHaveLength(0) + }) + + it('should handle consuming exercised events when scanning ledger', async () => { + ledgerProvider.request + .mockResolvedValueOnce({ offset: 300 }) + .mockResolvedValueOnce([ + { + update: { + Transaction: { + value: { + offset: 200, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract-1', + templateId: { + value: 'template1', + }, + contractKey: null, + createArgument: { + owner: 'party1', + }, + createdAt: + '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + }, + }, + ], + }, + }, + }, + }, + { + update: { + Transaction: { + value: { + offset: 300, + workflowId: 'wf2', + synchronizerId: 'sync1', + events: [ + { + ExercisedEvent: { + contractId: 'contract-1', + templateId: { + value: 'template1', + }, + consuming: true, + }, + }, + ], + }, + }, + }, + }, + ]) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + filterByParty: true, + } + + const result = await service.getActiveContracts(options) + + expect(result).toHaveLength(0) + }) + + it('should not remove contracts with non-consuming exercised events', async () => { + ledgerProvider.request + .mockResolvedValueOnce({ offset: 300 }) + .mockResolvedValueOnce([ + { + update: { + Transaction: { + value: { + offset: 200, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract-1', + templateId: { + value: 'template1', + }, + contractKey: null, + createArgument: { + owner: 'party1', + }, + createdAt: + '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + }, + }, + ], + }, + }, + }, + }, + { + update: { + Transaction: { + value: { + offset: 300, + workflowId: 'wf2', + synchronizerId: 'sync1', + events: [ + { + ExercisedEvent: { + contractId: 'contract-1', + templateId: { + value: 'template1', + }, + consuming: false, + }, + }, + ], + }, + }, + }, + }, + ]) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + filterByParty: true, + } + + const result = await service.getActiveContracts(options) + + expect(result).toHaveLength(1) + }) + + it('should handle multiple batches when scanning ledger', async () => { + ledgerProvider.request + .mockResolvedValueOnce({ offset: 400 }) + .mockResolvedValueOnce([ + { + update: { + Transaction: { + value: { + offset: 200, + workflowId: 'wf1', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract-1', + templateId: { + value: 'template1', + }, + contractKey: null, + createArgument: { + owner: 'party1', + }, + createdAt: + '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + }, + }, + ], + }, + }, + }, + }, + ]) + .mockResolvedValueOnce([ + { + update: { + Transaction: { + value: { + offset: 400, + workflowId: 'wf2', + synchronizerId: 'sync1', + events: [ + { + CreatedEvent: { + contractId: 'contract-2', + templateId: { + value: 'template1', + }, + contractKey: null, + createArgument: { + owner: 'party1', + }, + createdAt: + '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + }, + }, + ], + }, + }, + }, + }, + ]) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + filterByParty: true, + } + + const result = await service.getActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledTimes(3) + expect(result).toHaveLength(2) + }) + }) + + describe('buildActiveContractFilter', () => { + it('should build filter with template IDs', () => { + const filter = buildActiveContractFilter({ + offset: 100, + templateIds: ['template1', 'template2'], + }) + + expect(filter.activeAtOffset).toBe(100) + expect(filter.verbose).toBe(false) + expect(filter.filter?.filtersForAnyParty?.cumulative).toHaveLength( + 2 + ) + expect( + filter.filter?.filtersForAnyParty?.cumulative?.[0] + ?.identifierFilter + ).toHaveProperty('TemplateFilter') + }) + + it('should build filter with interface IDs', () => { + const filter = buildActiveContractFilter({ + offset: 100, + interfaceIds: ['interface1', 'interface2'], + }) + + expect(filter.activeAtOffset).toBe(100) + expect(filter.filter?.filtersForAnyParty?.cumulative).toHaveLength( + 2 + ) + expect( + filter.filter?.filtersForAnyParty?.cumulative?.[0] + ?.identifierFilter + ).toHaveProperty('InterfaceFilter') + }) + + it('should build filter by party with template IDs', () => { + const filter = buildActiveContractFilter({ + offset: 100, + parties: ['party1', 'party2'], + templateIds: ['template1'], + filterByParty: true, + }) + + expect(filter.filter?.filtersByParty).toHaveProperty('party1') + expect(filter.filter?.filtersByParty).toHaveProperty('party2') + expect( + filter.filter?.filtersByParty?.['party1']?.cumulative + ).toHaveLength(1) + }) + + it('should build filter by party with interface IDs', () => { + const filter = buildActiveContractFilter({ + offset: 100, + parties: ['party1'], + interfaceIds: ['interface1'], + filterByParty: true, + }) + + expect(filter.filter?.filtersByParty).toHaveProperty('party1') + expect( + filter.filter?.filtersByParty?.['party1']?.cumulative?.[0] + ?.identifierFilter + ).toHaveProperty('InterfaceFilter') + }) + + it('should use empty cumulative filter when filterByParty is true without templates or interfaces', () => { + const filter = buildActiveContractFilter({ + offset: 100, + parties: ['party1'], + filterByParty: true, + }) + + expect(filter.filter?.filtersByParty).toHaveProperty('party1') + expect( + filter.filter?.filtersByParty?.['party1']?.cumulative + ).toHaveLength(0) + }) + + it('should include template metadata in filter', () => { + const filter = buildActiveContractFilter({ + offset: 100, + templateIds: ['template1'], + }) + + const identifierFilter = + filter.filter?.filtersForAnyParty?.cumulative?.[0] + ?.identifierFilter + + expect(identifierFilter).toBeDefined() + if (identifierFilter && 'TemplateFilter' in identifierFilter) { + expect(identifierFilter.TemplateFilter.value.templateId).toBe( + 'template1' + ) + expect( + identifierFilter.TemplateFilter.value + .includeCreatedEventBlob + ).toBe(true) + } else { + throw new Error('Expected TemplateFilter in identifierFilter') + } + }) + + it('should include interface metadata in filter', () => { + const filter = buildActiveContractFilter({ + offset: 100, + interfaceIds: ['interface1'], + }) + + const identifierFilter = + filter.filter?.filtersForAnyParty?.cumulative?.[0] + ?.identifierFilter + + expect(identifierFilter).toBeDefined() + if (identifierFilter && 'InterfaceFilter' in identifierFilter) { + expect(identifierFilter.InterfaceFilter.value.interfaceId).toBe( + 'interface1' + ) + expect( + identifierFilter.InterfaceFilter.value + .includeCreatedEventBlob + ).toBe(true) + expect( + identifierFilter.InterfaceFilter.value.includeInterfaceView + ).toBe(true) + } else { + throw new Error('Expected InterfaceFilter in identifierFilter') + } + }) + + it('should handle offset 0', () => { + const filter = buildActiveContractFilter({ + offset: 0, + templateIds: ['template1'], + }) + + expect(filter.activeAtOffset).toBe(0) + }) + + it('should set activeAtOffset correctly', () => { + const filter = buildActiveContractFilter({ + offset: 12345, + parties: ['party1'], + filterByParty: true, + }) + + expect(filter.activeAtOffset).toBe(12345) + }) + }) + + describe('awaitCompletion', () => { + it('should return completion when found immediately', async () => { + const mockCompletion = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'cmd1', + submissionId: 'sub1', + offset: 200, + status: { code: 0, message: 'Success' }, + updateId: 'update1', + synchronizerId: 'sync1', + recordTime: '2024-01-01T00:00:00Z', + }, + }, + }, + } + + ledgerProvider.request.mockResolvedValue([mockCompletion]) + + const result = await awaitCompletion( + ledgerProvider, + 100, + 'party1', + 'user1', + 'cmd1' + ) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/commands/completions', + requestMethod: 'post', + body: { + userId: 'user1', + parties: ['party1'], + beginExclusive: 100, + }, + query: { + limit: 100, + stream_idle_timeout_ms: 1000, + }, + }, + }) + expect(result.commandId).toBe('cmd1') + }) + + it('should match by submissionId when provided', async () => { + const mockCompletion = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'cmd1', + submissionId: 'sub1', + offset: 200, + status: { code: 0, message: 'Success' }, + updateId: 'update1', + synchronizerId: 'sync1', + recordTime: '2024-01-01T00:00:00Z', + }, + }, + }, + } + + ledgerProvider.request.mockResolvedValue([mockCompletion]) + + const result = await awaitCompletion( + ledgerProvider, + 100, + 'party1', + 'user1', + 'sub1' + ) + + expect(result.submissionId).toBe('sub1') + }) + + it('should retry when completion not found', async () => { + const otherCompletion = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'other-cmd', + submissionId: 'other-sub', + offset: 150, + status: { code: 0, message: 'Success' }, + updateId: 'update1', + synchronizerId: 'sync1', + recordTime: '2024-01-01T00:00:00Z', + }, + }, + }, + } + + const wantedCompletion = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'cmd1', + submissionId: 'sub1', + offset: 200, + status: { code: 0, message: 'Success' }, + updateId: 'update1', + synchronizerId: 'sync1', + recordTime: '2024-01-01T00:00:00Z', + }, + }, + }, + } + + ledgerProvider.request + .mockResolvedValueOnce([otherCompletion]) + .mockResolvedValueOnce([wantedCompletion]) + + const result = await awaitCompletion( + ledgerProvider, + 100, + 'party1', + 'user1', + 'cmd1' + ) + + expect(ledgerProvider.request).toHaveBeenCalledTimes(2) + expect(result.commandId).toBe('cmd1') + }) + + it('should throw error when completion status code is not 0', async () => { + const failedCompletion = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'cmd1', + submissionId: 'sub1', + offset: 200, + status: { code: 1, message: 'Command failed' }, + updateId: 'update1', + synchronizerId: 'sync1', + recordTime: '2024-01-01T00:00:00Z', + }, + }, + }, + } + + ledgerProvider.request.mockResolvedValue([failedCompletion]) + + await expect( + awaitCompletion(ledgerProvider, 100, 'party1', 'user1', 'cmd1') + ).rejects.toThrow('Command failed with status code 1') + }) + + it('should use last completion offset for retry when no match found', async () => { + const completion1 = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'other1', + submissionId: 'other1', + offset: 150, + status: { code: 0 }, + }, + }, + }, + } + + const completion2 = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'other2', + submissionId: 'other2', + offset: 175, + status: { code: 0 }, + }, + }, + }, + } + + const wantedCompletion = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'cmd1', + submissionId: 'sub1', + offset: 200, + status: { code: 0 }, + }, + }, + }, + } + + ledgerProvider.request + .mockResolvedValueOnce([completion1, completion2]) + .mockResolvedValueOnce([wantedCompletion]) + + await awaitCompletion( + ledgerProvider, + 100, + 'party1', + 'user1', + 'cmd1' + ) + + expect(ledgerProvider.request).toHaveBeenNthCalledWith(2, { + method: 'ledgerApi', + params: { + resource: '/v2/commands/completions', + requestMethod: 'post', + body: { + userId: 'user1', + parties: ['party1'], + beginExclusive: 175, + }, + query: { + limit: 100, + stream_idle_timeout_ms: 1000, + }, + }, + }) + }) + + it('should use original ledger end when response is empty', async () => { + const wantedCompletion = { + completionResponse: { + Completion: { + value: { + userId: 'user1', + commandId: 'cmd1', + submissionId: 'sub1', + offset: 200, + status: { code: 0 }, + }, + }, + }, + } + + ledgerProvider.request + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([wantedCompletion]) + + await awaitCompletion( + ledgerProvider, + 100, + 'party1', + 'user1', + 'cmd1' + ) + + expect(ledgerProvider.request).toHaveBeenNthCalledWith(2, { + method: 'ledgerApi', + params: { + resource: '/v2/commands/completions', + requestMethod: 'post', + body: { + userId: 'user1', + parties: ['party1'], + beginExclusive: 100, + }, + query: { + limit: 100, + stream_idle_timeout_ms: 1000, + }, + }, + }) + }) + }) + + describe('promiseWithTimeout', () => { + it('should resolve when promise completes before timeout', async () => { + const promise = Promise.resolve('success') + + const result = await promiseWithTimeout(promise, 1000, 'Timeout') + + expect(result).toBe('success') + }) + + it('should reject with error message when timeout is reached', async () => { + const promise = new Promise((resolve) => + setTimeout(() => resolve('success'), 100) + ) + + await expect( + promiseWithTimeout(promise, 10, 'Timeout error') + ).rejects.toBe('Timeout error') + }) + + it('should clear timeout when promise resolves', async () => { + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') + const promise = Promise.resolve('success') + + await promiseWithTimeout(promise, 1000, 'Timeout') + + expect(clearTimeoutSpy).toHaveBeenCalled() + clearTimeoutSpy.mockRestore() + }) + + it('should clear timeout when promise rejects', async () => { + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout') + const promise = Promise.reject(new Error('Promise error')) + + await expect( + promiseWithTimeout(promise, 1000, 'Timeout') + ).rejects.toThrow('Promise error') + + expect(clearTimeoutSpy).toHaveBeenCalled() + clearTimeoutSpy.mockRestore() + }) + + it('should handle promise rejection before timeout', async () => { + const promise = Promise.reject(new Error('Failed')) + + await expect( + promiseWithTimeout(promise, 1000, 'Timeout') + ).rejects.toThrow('Failed') + }) + + it('should handle immediate promise resolution', async () => { + const promise = Promise.resolve(42) + + const result = await promiseWithTimeout(promise, 1000, 'Timeout') + + expect(result).toBe(42) + }) + }) +}) From f02745e646dbbd4ca0566c868deea3161adea8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Mon, 1 Jun 2026 14:37:48 +0200 Subject: [PATCH 05/13] fix(core-acs-reader): undo vitest config modification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- core/acs-reader/vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/acs-reader/vitest.config.ts b/core/acs-reader/vitest.config.ts index 6d8591613..6d6980ae1 100644 --- a/core/acs-reader/vitest.config.ts +++ b/core/acs-reader/vitest.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ browser: { enabled: true, provider: playwright({ + trace: 'off', screenshot: 'off', video: 'off', }), From e9363aca7caa1932027030340cbdb9c9b5ef3b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Tue, 2 Jun 2026 10:16:23 +0200 Subject: [PATCH 06/13] test(core-acs-reader): remove redundant test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- core/acs-reader/src/__test__/reader.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/core/acs-reader/src/__test__/reader.test.ts b/core/acs-reader/src/__test__/reader.test.ts index d1ceba781..335188e79 100644 --- a/core/acs-reader/src/__test__/reader.test.ts +++ b/core/acs-reader/src/__test__/reader.test.ts @@ -451,16 +451,5 @@ describe('reader', () => { 'Ledger error' ) }) - - it('should handle null offset from ledger-end gracefully', async () => { - ledgerProvider.request.mockResolvedValue({ offset: null }) - - await reader.read({ parties: ['party1'] }) - - expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith({ - parties: ['party1'], - offset: null, - }) - }) }) }) From 75314184e808c0b4c845de09850f97ec94f437d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Wed, 10 Jun 2026 17:26:10 +0200 Subject: [PATCH 07/13] test(core-acs-reader): add tests for collection & cache items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- .../cache/collection}/collection.test.ts | 91 +--- .../cache/item/item.test.ts} | 49 +- .../__test__/cache/item/paginatedItem.test.ts | 473 ++++++++++++++++++ core/acs-reader/vitest.config.ts | 8 +- 4 files changed, 500 insertions(+), 121 deletions(-) rename core/acs-reader/src/{cache/__test__ => __test__/cache/collection}/collection.test.ts (70%) rename core/acs-reader/src/{cache/__test__/cache.test.ts => __test__/cache/item/item.test.ts} (90%) create mode 100644 core/acs-reader/src/__test__/cache/item/paginatedItem.test.ts diff --git a/core/acs-reader/src/cache/__test__/collection.test.ts b/core/acs-reader/src/__test__/cache/collection/collection.test.ts similarity index 70% rename from core/acs-reader/src/cache/__test__/collection.test.ts rename to core/acs-reader/src/__test__/cache/collection/collection.test.ts index a6998f91c..447cd2975 100644 --- a/core/acs-reader/src/cache/__test__/collection.test.ts +++ b/core/acs-reader/src/__test__/cache/collection/collection.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ACSCacheCollection } from '../collection' +import { ACSCacheCollection } from '../../../cache/collection' const { mockCache, MockACSCache } = vi.hoisted(() => { const update = vi.fn() @@ -23,9 +23,13 @@ const { mockCache, MockACSCache } = vi.hoisted(() => { return { mockCache, MockACSCache } }) -vi.mock('../cache', () => ({ - ACSCache: MockACSCache, -})) +vi.mock('../../../cache/item', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ACSCache: MockACSCache, + } +}) const ledgerProvider = vi.hoisted(() => ({ request: vi.fn(), @@ -36,7 +40,6 @@ describe('cache collection', () => { beforeEach(() => { vi.clearAllMocks() - mockCache.update.mockResolvedValue(undefined) mockCache.calculateAt.mockReturnValue([ { workflowId: 'test-workflow', @@ -56,11 +59,9 @@ describe('cache collection', () => { collection = new ACSCacheCollection(ledgerProvider) }) - it('should create cache collection with default options', () => { + it('should create cache collection with custom options', () => { expect(collection).toBeDefined() - }) - it('should create cache collection with custom options', () => { const customCollection = new ACSCacheCollection(ledgerProvider, { maxSize: 50, entryExpirationTimeInMS: 5 * 60 * 1000, @@ -88,16 +89,16 @@ describe('cache collection', () => { const options = { offset: 100, parties: ['party1', 'party2'], - templateIds: ['template1', 'template2'], + templateIds: ['template1', 'template2', 'template3'], } const result = await collection.readFromCache(options) - // Should create 4 cache instances (2 parties × 2 templates) - expect(MockACSCache).toHaveBeenCalledTimes(4) - expect(mockCache.update).toHaveBeenCalledTimes(4) - expect(mockCache.calculateAt).toHaveBeenCalledTimes(4) - expect(result).toHaveLength(4) + // Should create 6 cache instances (2 parties × 3 templates) + expect(MockACSCache).toHaveBeenCalledTimes(6) + expect(mockCache.update).toHaveBeenCalledTimes(6) + expect(mockCache.calculateAt).toHaveBeenCalledTimes(6) + expect(result).toHaveLength(6) }) it('should read from cache with parties and interfaces', async () => { @@ -195,67 +196,5 @@ describe('cache collection', () => { // 2 templates × 2 contracts per template = 4 total contracts expect(result).toHaveLength(4) }) - - it('should query all keys in parallel', async () => { - const updatePromises: Array<() => void> = [] - mockCache.update.mockImplementation( - () => - new Promise((resolve) => { - updatePromises.push(() => resolve(undefined)) - }) - ) - - const options = { - offset: 100, - parties: ['party1', 'party2'], - templateIds: ['template1', 'template2'], - } - - const resultPromise = collection.readFromCache(options) - - // Wait a bit to ensure all updates are called - await new Promise((resolve) => setTimeout(resolve, 10)) - - // All 4 updates should be called before any resolves - expect(mockCache.update).toHaveBeenCalledTimes(4) - expect(mockCache.calculateAt).not.toHaveBeenCalled() - - // Resolve all promises - updatePromises.forEach((resolve) => resolve()) - - await resultPromise - - expect(mockCache.calculateAt).toHaveBeenCalledTimes(4) - }) - - it('should handle cache update errors', async () => { - mockCache.update.mockRejectedValue(new Error('Update failed')) - - const options = { - offset: 100, - parties: ['party1'], - templateIds: ['template1'], - } - - await expect(collection.readFromCache(options)).rejects.toThrow( - 'Update failed' - ) - }) - - it('should handle calculateAt errors', async () => { - mockCache.calculateAt.mockImplementation(() => { - throw new Error('Calculate failed') - }) - - const options = { - offset: 100, - parties: ['party1'], - templateIds: ['template1'], - } - - await expect(collection.readFromCache(options)).rejects.toThrow( - 'Calculate failed' - ) - }) }) }) diff --git a/core/acs-reader/src/cache/__test__/cache.test.ts b/core/acs-reader/src/__test__/cache/item/item.test.ts similarity index 90% rename from core/acs-reader/src/cache/__test__/cache.test.ts rename to core/acs-reader/src/__test__/cache/item/item.test.ts index 5705038d8..3ce7434f1 100644 --- a/core/acs-reader/src/cache/__test__/cache.test.ts +++ b/core/acs-reader/src/__test__/cache/item/item.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ACSCache } from '../cache' +import { ACSCache } from '../../../cache/item' const { getActiveContracts, MockACSService } = vi.hoisted(() => { const getActiveContracts = vi.fn() @@ -16,8 +16,8 @@ const { getActiveContracts, MockACSService } = vi.hoisted(() => { return { getActiveContracts, MockACSService } }) -vi.mock('../../service.ts', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../service.ts', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, AcsService: MockACSService, @@ -28,7 +28,7 @@ const ledgerProvider = vi.hoisted(() => ({ request: vi.fn(), })) -describe('cache', () => { +describe('cache - item', () => { let cache: ACSCache beforeEach(() => { @@ -123,41 +123,6 @@ describe('cache', () => { ) }) - it('should update offset and concatenate events when new events are found', async () => { - const mockUpdates = [ - { - update: { - Transaction: { - value: { - offset: 150, - workflowId: 'wf1', - synchronizerId: 'sync1', - events: [ - { - CreatedEvent: { - contractId: 'contract1', - templateId: { value: 'template1' }, - contractKey: null, - createArguments: {}, - createdAt: '2024-01-01T00:00:00Z', - signatories: [], - observers: [], - }, - }, - ], - }, - }, - }, - }, - ] - ledgerProvider.request.mockResolvedValue(mockUpdates) - - await cache.update({ offset: 100 }) - - // Verify the update was processed without calling calculateAt - expect(ledgerProvider.request).toHaveBeenCalled() - }) - it('should recursively call update when reaching maxUpdatesToFetch', async () => { // Create exactly 100 updates (the maxUpdatesToFetch limit) const mockUpdates = Array.from({ length: 100 }, (_, i) => ({ @@ -272,7 +237,7 @@ describe('cache', () => { c.contractEntry.JsActiveContract.createdEvent .contractId === 'new-contract-1' ) - ) + ).toBe(true) }) it('should handle multiple created and archived events correctly', async () => { @@ -477,7 +442,9 @@ describe('cache', () => { const result = cache.calculateAt(200) // Should filter out any entries without contractEntry - expect(result.every(Boolean)).toBe(true) + expect( + result.every(({ contractEntry }) => Boolean(contractEntry)) + ).toBe(true) }) }) }) diff --git a/core/acs-reader/src/__test__/cache/item/paginatedItem.test.ts b/core/acs-reader/src/__test__/cache/item/paginatedItem.test.ts new file mode 100644 index 000000000..4ff0e1fd1 --- /dev/null +++ b/core/acs-reader/src/__test__/cache/item/paginatedItem.test.ts @@ -0,0 +1,473 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PaginatedACSCache } from '../../../cache/item' + +const { getPaginatedActiveContracts, MockACSService } = vi.hoisted(() => { + const getPaginatedActiveContracts = vi.fn() + + const MockACSService = vi.fn( + class { + getPaginatedActiveContracts = getPaginatedActiveContracts + } + ) + + return { getPaginatedActiveContracts, MockACSService } +}) + +vi.mock('../../../service.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + AcsService: MockACSService, + } +}) + +const ledgerProvider = vi.hoisted(() => ({ + request: vi.fn(), +})) + +describe('cache - paginated item', () => { + let cache: PaginatedACSCache + + beforeEach(() => { + vi.clearAllMocks() + + getPaginatedActiveContracts.mockResolvedValue({ + activeContracts: [ + { + workflowId: 'id1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'initial-contract-1', + templateId: 'template1', + offset: 99, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + { + workflowId: 'id2', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'initial-contract-2', + templateId: 'template2', + offset: 100, + }, + synchronizerId: 'sync2', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 100, + nextPageToken: '', + }) + + cache = new PaginatedACSCache(ledgerProvider) + }) + + it('should call ACSService upon init', () => { + expect(MockACSService).toHaveBeenCalledOnce() + }) + + describe('update', () => { + it('should init state when cache is empty', async () => { + const updateOptions = { + offset: 100, + } + await cache.update(updateOptions) + expect(getPaginatedActiveContracts).toHaveBeenCalledExactlyOnceWith( + updateOptions + ) + }) + + it('should loop through pages until offset is reached', async () => { + getPaginatedActiveContracts + .mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-page1', + templateId: 'template1', + offset: 100, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 100, + nextPageToken: 'page2', + }) + .mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id2', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-page2', + templateId: 'template2', + offset: 200, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 200, + nextPageToken: 'page3', + }) + .mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id3', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-page3', + templateId: 'template3', + offset: 300, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 300, + nextPageToken: '', + }) + + await cache.update({ offset: 300 }) + + // Should have called getPaginatedActiveContracts 3 times (initial + 2 more pages) + expect(getPaginatedActiveContracts).toHaveBeenCalledTimes(3) + expect(getPaginatedActiveContracts).toHaveBeenNthCalledWith(1, { + offset: 300, + }) + expect(getPaginatedActiveContracts).toHaveBeenNthCalledWith(2, { + offset: 300, + pageToken: 'page2', + }) + expect(getPaginatedActiveContracts).toHaveBeenNthCalledWith(3, { + offset: 300, + pageToken: 'page3', + }) + }) + + it('should stop pagination when nextPageToken is empty', async () => { + await cache.update({ offset: 100 }) + + // Try to update with higher offset, but no more pages available + await cache.update({ offset: 200 }) + + // Should only have called once (initial), not loop since nextPageToken is empty + expect(getPaginatedActiveContracts).toHaveBeenCalledOnce() + }) + + it('should stop pagination when offset is reached', async () => { + getPaginatedActiveContracts + .mockResolvedValueOnce({ + activeContracts: [], + activeAtOffset: 100, + nextPageToken: 'page2', + }) + .mockResolvedValueOnce({ + activeContracts: [], + activeAtOffset: 200, + nextPageToken: 'page3', + }) + + await cache.update({ offset: 150 }) + + // Should have called twice: initial fetch reaches offset 100, + // then fetches page2 which reaches offset 200 (> 150), so stops + expect(getPaginatedActiveContracts).toHaveBeenCalledTimes(2) + }) + }) + + describe('calculateAt', () => { + it('should throw error when no ACS is initialized', () => { + const emptyCache = new PaginatedACSCache(ledgerProvider) + expect(() => emptyCache.calculateAt(100)).toThrow() + }) + + it('should return contracts at specified offset', async () => { + await cache.update({ offset: 100 }) + + const result = cache.calculateAt(100) + + expect(result).toHaveLength(2) + expect(result[0].contractEntry).toBeDefined() + expect(result[1].contractEntry).toBeDefined() + }) + + it("shouldn't return contracts for wrong offset", async () => { + await cache.update({ offset: 100 }) + + const emptyResult = cache.calculateAt(0) + expect(emptyResult).toHaveLength(0) + }) + + it('should filter contracts by offset from createdEvent', async () => { + getPaginatedActiveContracts.mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-100', + templateId: 'template1', + offset: 100, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + { + workflowId: 'id2', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-150', + templateId: 'template2', + offset: 150, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + { + workflowId: 'id3', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-200', + templateId: 'template3', + offset: 200, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 200, + nextPageToken: '', + }) + + await cache.update({ offset: 200 }) + + const resultAt100 = cache.calculateAt(100) + expect(resultAt100).toHaveLength(1) // Only contract-100 + + const resultAt150 = cache.calculateAt(150) + expect(resultAt150).toHaveLength(2) // contract-100 and contract-150 + + const resultAt200 = cache.calculateAt(200) + expect(resultAt200).toHaveLength(3) // All three contracts + }) + + it('should aggregate contracts from multiple pages', async () => { + getPaginatedActiveContracts + .mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'page1-contract', + templateId: 'template1', + offset: 100, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 100, + nextPageToken: 'page2', + }) + .mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id2', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'page2-contract', + templateId: 'template2', + offset: 200, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 200, + nextPageToken: '', + }) + + await cache.update({ offset: 200 }) + + const result = cache.calculateAt(200) + + // Should have contracts from both pages + expect(result).toHaveLength(2) + expect( + result.some( + (c) => + c.contractEntry && + 'JsActiveContract' in c.contractEntry && + c.contractEntry.JsActiveContract.createdEvent + .contractId === 'page1-contract' + ) + ).toBe(true) + expect( + result.some( + (c) => + c.contractEntry && + 'JsActiveContract' in c.contractEntry && + c.contractEntry.JsActiveContract.createdEvent + .contractId === 'page2-contract' + ) + ).toBe(true) + }) + + it('should handle contracts without JsActiveContract gracefully', async () => { + getPaginatedActiveContracts.mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'valid-contract', + templateId: 'template1', + offset: 100, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + { + workflowId: 'id2', + contractEntry: undefined, + }, + ], + activeAtOffset: 100, + nextPageToken: '', + }) + + await cache.update({ offset: 100 }) + + const result = cache.calculateAt(100) + + // Should filter out entries without JsActiveContract + expect(result).toHaveLength(1) + expect( + result[0].contractEntry && + 'JsActiveContract' in result[0].contractEntry && + result[0].contractEntry.JsActiveContract.createdEvent + .contractId + ).toBe('valid-contract') + }) + }) + + describe('getPage', () => { + it('should retrieve a specific page by token', async () => { + getPaginatedActiveContracts.mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-1', + templateId: 'template1', + offset: 100, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 100, + nextPageToken: 'page2', + }) + + await cache.update({ offset: 100 }) + + const firstPage = cache.getPage(PaginatedACSCache.FIRST_PAGE_TOKEN) + expect(firstPage).toBeDefined() + expect(firstPage.activeContracts).toHaveLength(1) + expect(firstPage.activeAtOffset).toBe(100) + expect(firstPage.nextPageToken).toBe('page2') + }) + + it('should return undefined for non-existent page token', async () => { + await cache.update({ offset: 100 }) + + const nonExistentPage = cache.getPage('non-existent-token') + expect(nonExistentPage).toBeUndefined() + }) + + it('should retrieve page after fetching it with pageToken option', async () => { + await cache.update({ offset: 100 }) + + getPaginatedActiveContracts.mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'id3', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'page2-contract', + templateId: 'template3', + offset: 200, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 200, + nextPageToken: '', + }) + + await cache.update({ offset: 200, pageToken: 'page2Token' }) + + const page2 = cache.getPage('page2Token') + expect(page2).toBeDefined() + expect(page2.activeContracts).toHaveLength(1) + expect( + page2.activeContracts[0].contractEntry && + 'JsActiveContract' in page2.activeContracts[0].contractEntry + ? page2.activeContracts[0].contractEntry.JsActiveContract + .createdEvent.contractId + : undefined + ).toBe('page2-contract') + }) + }) +}) diff --git a/core/acs-reader/vitest.config.ts b/core/acs-reader/vitest.config.ts index 6d6980ae1..dfea32869 100644 --- a/core/acs-reader/vitest.config.ts +++ b/core/acs-reader/vitest.config.ts @@ -11,10 +11,10 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'html', 'lcov'], thresholds: { - lines: 0, - functions: 0, - branches: 0, - statements: 0, + lines: 80, + functions: 80, + branches: 70, + statements: 80, }, }, environment: 'node', From 43b90e46b13a8316097a00b7e7dee0d5f3f006d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Thu, 11 Jun 2026 10:41:14 +0200 Subject: [PATCH 08/13] test(core-acs-reader): continue working on tests :wip: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- .../src/__test__/cache/collection.test.ts | 219 ++++++++++++++ .../cache/collection/collection.test.ts | 200 ------------- core/acs-reader/src/__test__/reader.test.ts | 2 +- core/acs-reader/src/__test__/service.test.ts | 268 ++++++++++++++++++ 4 files changed, 488 insertions(+), 201 deletions(-) create mode 100644 core/acs-reader/src/__test__/cache/collection.test.ts delete mode 100644 core/acs-reader/src/__test__/cache/collection/collection.test.ts diff --git a/core/acs-reader/src/__test__/cache/collection.test.ts b/core/acs-reader/src/__test__/cache/collection.test.ts new file mode 100644 index 000000000..ff3b37e4b --- /dev/null +++ b/core/acs-reader/src/__test__/cache/collection.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ACSCacheCollection, + PaginatedACSCacheCollection, +} from '../../cache/collection' + +const { mockCache, MockACSCache } = vi.hoisted(() => { + const update = vi.fn() + const calculateAt = vi.fn() + + const mockCache = { + update, + calculateAt, + } + + const MockACSCache = vi.fn( + class { + update = update + calculateAt = calculateAt + } + ) + + return { mockCache, MockACSCache } +}) + +vi.mock('../../cache/item', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ACSCache: MockACSCache, + PaginatedACSCache: MockACSCache, + } +}) + +const ledgerProvider = vi.hoisted(() => ({ + request: vi.fn(), +})) + +describe('cache collection', () => { + ;[ACSCacheCollection, PaginatedACSCacheCollection].forEach( + (cacheConstructor) => { + describe(`using ${cacheConstructor.name}`, () => { + let collection: ACSCacheCollection | PaginatedACSCacheCollection + + beforeEach(() => { + vi.clearAllMocks() + mockCache.calculateAt.mockReturnValue([ + { + workflowId: 'test-workflow', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-1', + templateId: 'template1', + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ]) + + collection = new cacheConstructor(ledgerProvider) + }) + + it('should create cache collection with custom options', () => { + expect(collection).toBeDefined() + + const customCollection = new cacheConstructor( + ledgerProvider, + { + maxSize: 50, + entryExpirationTimeInMS: 5 * 60 * 1000, + } + ) + expect(customCollection).toBeDefined() + }) + + describe('readFromCache', () => { + it('should read from cache with single party and template', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + const result = await collection.readFromCache(options) + + expect(MockACSCache).toHaveBeenCalledWith( + ledgerProvider + ) + expect(mockCache.update).toHaveBeenCalledWith(options) + expect(mockCache.calculateAt).toHaveBeenCalledWith(100) + expect(result).toHaveLength(1) + }) + + it('should read from cache with multiple parties and templates', async () => { + const options = { + offset: 100, + parties: ['party1', 'party2'], + templateIds: [ + 'template1', + 'template2', + 'template3', + ], + } + + const result = await collection.readFromCache(options) + + // Should create 6 cache instances (2 parties × 3 templates) + expect(MockACSCache).toHaveBeenCalledTimes(6) + expect(mockCache.update).toHaveBeenCalledTimes(6) + expect(mockCache.calculateAt).toHaveBeenCalledTimes(6) + expect(result).toHaveLength(6) + }) + + it('should read from cache with parties and interfaces', async () => { + const options = { + offset: 100, + parties: ['party1'], + interfaceIds: ['interface1', 'interface2'], + } + + const result = await collection.readFromCache(options) + + // Should create 2 cache instances (1 party × 2 interfaces) + expect(MockACSCache).toHaveBeenCalledTimes(2) + expect(mockCache.update).toHaveBeenCalledTimes(2) + expect(result).toHaveLength(2) + }) + + it('should read from cache with parties, templates, and interfaces', async () => { + const options = { + offset: 150, + parties: ['party1'], + templateIds: ['template1'], + interfaceIds: ['interface1'], + } + + const result = await collection.readFromCache(options) + + // Should create 2 cache instances (1 for interface, 1 for template) + expect(MockACSCache).toHaveBeenCalledTimes(2) + expect(mockCache.update).toHaveBeenCalledTimes(2) + expect(mockCache.calculateAt).toHaveBeenCalledWith(150) + expect(result).toHaveLength(2) + }) + + it('should reuse existing cache for same key', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + await collection.readFromCache(options) + await collection.readFromCache(options) + + // Should only create cache once + expect(MockACSCache).toHaveBeenCalledOnce() + // But should update and calculate twice + expect(mockCache.update).toHaveBeenCalledTimes(2) + expect(mockCache.calculateAt).toHaveBeenCalledTimes(2) + }) + + it('should create different caches for different keys', async () => { + await collection.readFromCache({ + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + }) + + await collection.readFromCache({ + offset: 100, + parties: ['party2'], + templateIds: ['template1'], + }) + + // Should create two different caches + expect(MockACSCache).toHaveBeenCalledTimes(2) + }) + + it('should flatten results from multiple queries', async () => { + mockCache.calculateAt.mockReturnValue([ + { + contractEntry: { + JsActiveContract: { + createdEvent: { contractId: 'c1' }, + }, + }, + }, + { + contractEntry: { + JsActiveContract: { + createdEvent: { contractId: 'c2' }, + }, + }, + }, + ]) + + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1', 'template2'], + } + + const result = await collection.readFromCache(options) + + // 2 templates × 2 contracts per template = 4 total contracts + expect(result).toHaveLength(4) + }) + }) + }) + } + ) +}) diff --git a/core/acs-reader/src/__test__/cache/collection/collection.test.ts b/core/acs-reader/src/__test__/cache/collection/collection.test.ts deleted file mode 100644 index 447cd2975..000000000 --- a/core/acs-reader/src/__test__/cache/collection/collection.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ACSCacheCollection } from '../../../cache/collection' - -const { mockCache, MockACSCache } = vi.hoisted(() => { - const update = vi.fn() - const calculateAt = vi.fn() - - const mockCache = { - update, - calculateAt, - } - - const MockACSCache = vi.fn( - class { - update = update - calculateAt = calculateAt - } - ) - - return { mockCache, MockACSCache } -}) - -vi.mock('../../../cache/item', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - ACSCache: MockACSCache, - } -}) - -const ledgerProvider = vi.hoisted(() => ({ - request: vi.fn(), -})) - -describe('cache collection', () => { - let collection: ACSCacheCollection - - beforeEach(() => { - vi.clearAllMocks() - mockCache.calculateAt.mockReturnValue([ - { - workflowId: 'test-workflow', - contractEntry: { - JsActiveContract: { - createdEvent: { - contractId: 'contract-1', - templateId: 'template1', - }, - synchronizerId: 'sync1', - reassignmentCounter: 0, - }, - }, - }, - ]) - - collection = new ACSCacheCollection(ledgerProvider) - }) - - it('should create cache collection with custom options', () => { - expect(collection).toBeDefined() - - const customCollection = new ACSCacheCollection(ledgerProvider, { - maxSize: 50, - entryExpirationTimeInMS: 5 * 60 * 1000, - }) - expect(customCollection).toBeDefined() - }) - - describe('readFromCache', () => { - it('should read from cache with single party and template', async () => { - const options = { - offset: 100, - parties: ['party1'], - templateIds: ['template1'], - } - - const result = await collection.readFromCache(options) - - expect(MockACSCache).toHaveBeenCalledWith(ledgerProvider) - expect(mockCache.update).toHaveBeenCalledWith(options) - expect(mockCache.calculateAt).toHaveBeenCalledWith(100) - expect(result).toHaveLength(1) - }) - - it('should read from cache with multiple parties and templates', async () => { - const options = { - offset: 100, - parties: ['party1', 'party2'], - templateIds: ['template1', 'template2', 'template3'], - } - - const result = await collection.readFromCache(options) - - // Should create 6 cache instances (2 parties × 3 templates) - expect(MockACSCache).toHaveBeenCalledTimes(6) - expect(mockCache.update).toHaveBeenCalledTimes(6) - expect(mockCache.calculateAt).toHaveBeenCalledTimes(6) - expect(result).toHaveLength(6) - }) - - it('should read from cache with parties and interfaces', async () => { - const options = { - offset: 100, - parties: ['party1'], - interfaceIds: ['interface1', 'interface2'], - } - - const result = await collection.readFromCache(options) - - // Should create 2 cache instances (1 party × 2 interfaces) - expect(MockACSCache).toHaveBeenCalledTimes(2) - expect(mockCache.update).toHaveBeenCalledTimes(2) - expect(result).toHaveLength(2) - }) - - it('should read from cache with parties, templates, and interfaces', async () => { - const options = { - offset: 150, - parties: ['party1'], - templateIds: ['template1'], - interfaceIds: ['interface1'], - } - - const result = await collection.readFromCache(options) - - // Should create 2 cache instances (1 for interface, 1 for template) - expect(MockACSCache).toHaveBeenCalledTimes(2) - expect(mockCache.update).toHaveBeenCalledTimes(2) - expect(mockCache.calculateAt).toHaveBeenCalledWith(150) - expect(result).toHaveLength(2) - }) - - it('should reuse existing cache for same key', async () => { - const options = { - offset: 100, - parties: ['party1'], - templateIds: ['template1'], - } - - await collection.readFromCache(options) - await collection.readFromCache(options) - - // Should only create cache once - expect(MockACSCache).toHaveBeenCalledOnce() - // But should update and calculate twice - expect(mockCache.update).toHaveBeenCalledTimes(2) - expect(mockCache.calculateAt).toHaveBeenCalledTimes(2) - }) - - it('should create different caches for different keys', async () => { - await collection.readFromCache({ - offset: 100, - parties: ['party1'], - templateIds: ['template1'], - }) - - await collection.readFromCache({ - offset: 100, - parties: ['party2'], - templateIds: ['template1'], - }) - - // Should create two different caches - expect(MockACSCache).toHaveBeenCalledTimes(2) - }) - - it('should flatten results from multiple queries', async () => { - mockCache.calculateAt.mockReturnValue([ - { - contractEntry: { - JsActiveContract: { - createdEvent: { contractId: 'c1' }, - }, - }, - }, - { - contractEntry: { - JsActiveContract: { - createdEvent: { contractId: 'c2' }, - }, - }, - }, - ]) - - const options = { - offset: 100, - parties: ['party1'], - templateIds: ['template1', 'template2'], - } - - const result = await collection.readFromCache(options) - - // 2 templates × 2 contracts per template = 4 total contracts - expect(result).toHaveLength(4) - }) - }) -}) diff --git a/core/acs-reader/src/__test__/reader.test.ts b/core/acs-reader/src/__test__/reader.test.ts index 335188e79..d835183e8 100644 --- a/core/acs-reader/src/__test__/reader.test.ts +++ b/core/acs-reader/src/__test__/reader.test.ts @@ -48,7 +48,7 @@ const ledgerProvider = vi.hoisted(() => ({ request: vi.fn(), })) -describe('reader', () => { +describe.skip('reader', () => { let reader: ACSReader const mockActiveContracts = [ diff --git a/core/acs-reader/src/__test__/service.test.ts b/core/acs-reader/src/__test__/service.test.ts index 16ed8cfa1..329650ee7 100644 --- a/core/acs-reader/src/__test__/service.test.ts +++ b/core/acs-reader/src/__test__/service.test.ts @@ -8,6 +8,7 @@ import { buildActiveContractFilter, promiseWithTimeout, } from '../service' +import { PaginatedACSCache } from '../cache/item' const ledgerProvider = vi.hoisted(() => ({ request: vi.fn(), @@ -40,6 +41,7 @@ describe('service', () => { beforeEach(() => { vi.clearAllMocks() ledgerProvider.request.mockResolvedValue(mockActiveContracts) + service = new AcsService(ledgerProvider) }) @@ -468,6 +470,272 @@ describe('service', () => { }) }) + describe('getPaginatedActiveContracts', () => { + const mockPageResponse = { + activeContracts: [ + { + workflowId: 'wf1', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-1', + templateId: 'template1', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + offset: 100, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 100, + nextPageToken: '', + } + + it('should properly construct request body when specific args are provided', async () => { + ledgerProvider.request.mockResolvedValue(mockPageResponse) + + const options = { + offset: 100, + parties: ['party1'], + pageToken: 'page2Token', + maxPageSize: 50, + } + + await service.getPaginatedActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/state/active-contracts-page', + requestMethod: 'get', + body: expect.objectContaining({ + pageToken: 'page2Token', + maxPageSize: 50, + activeAtOffset: 100, + eventFormat: expect.any(Object), + }), + }, + }) + }) + + it('should include filtersByParty when filterByParty is true', async () => { + ledgerProvider.request.mockResolvedValue(mockPageResponse) + + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + filterByParty: true, + } + + await service.getPaginatedActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/state/active-contracts-page', + requestMethod: 'get', + body: expect.objectContaining({ + eventFormat: expect.objectContaining({ + filtersByParty: expect.any(Object), + }), + }), + }, + }) + }) + + it('should include filtersForAnyParty when filterByParty is false', async () => { + ledgerProvider.request.mockResolvedValue(mockPageResponse) + + const options = { + offset: 100, + templateIds: ['template1'], + } + + await service.getPaginatedActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { + resource: '/v2/state/active-contracts-page', + requestMethod: 'get', + body: expect.objectContaining({ + eventFormat: expect.objectContaining({ + filtersForAnyParty: expect.any(Object), + }), + }), + }, + }) + }) + + it('should fetch all pages when continueUntilCompletion is true', async () => { + ledgerProvider.request + .mockResolvedValueOnce({ + activeContracts: [mockPageResponse.activeContracts[0]], + activeAtOffset: 100, + nextPageToken: 'page2', + }) + .mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'wf2', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-2', + templateId: 'template1', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + offset: 200, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 200, + nextPageToken: 'page3', + }) + .mockResolvedValueOnce({ + activeContracts: [ + { + workflowId: 'wf3', + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: 'contract-3', + templateId: 'template1', + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: ['party1'], + observers: [], + offset: 300, + }, + synchronizerId: 'sync1', + reassignmentCounter: 0, + }, + }, + }, + ], + activeAtOffset: 300, + nextPageToken: '', + }) + + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + continueUntilCompletion: true, + } + + const result = await service.getPaginatedActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledTimes(3) + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(3) + if (Array.isArray(result)) { + expect(result[0].activeAtOffset).toBe(100) + expect(result[1].activeAtOffset).toBe(200) + expect(result[2].activeAtOffset).toBe(300) + } + }) + + it('should stop pagination when nextPageToken is empty', async () => { + ledgerProvider.request + .mockResolvedValueOnce({ + activeContracts: [mockPageResponse.activeContracts[0]], + activeAtOffset: 100, + nextPageToken: 'page2', + }) + .mockResolvedValueOnce({ + activeContracts: [], + activeAtOffset: 200, + nextPageToken: '', + }) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + } + + const result = await service.getPaginatedActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenCalledTimes(2) + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(2) + }) + + it('should use correct pageToken in subsequent requests', async () => { + ledgerProvider.request + .mockResolvedValueOnce({ + activeContracts: [], + activeAtOffset: 100, + nextPageToken: 'customToken1', + }) + .mockResolvedValueOnce({ + activeContracts: [], + activeAtOffset: 200, + nextPageToken: 'customToken2', + }) + .mockResolvedValueOnce({ + activeContracts: [], + activeAtOffset: 300, + nextPageToken: '', + }) + + const options = { + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + } + + await service.getPaginatedActiveContracts(options) + + expect(ledgerProvider.request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + params: expect.objectContaining({ + body: expect.not.objectContaining({ + pageToken: PaginatedACSCache.FIRST_PAGE_TOKEN, + }), + }), + }) + ) + expect(ledgerProvider.request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + params: expect.objectContaining({ + body: expect.objectContaining({ + pageToken: 'customToken1', + }), + }), + }) + ) + expect(ledgerProvider.request).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + params: expect.objectContaining({ + body: expect.objectContaining({ + pageToken: 'customToken2', + }), + }), + }) + ) + }) + }) + describe('buildActiveContractFilter', () => { it('should build filter with template IDs', () => { const filter = buildActiveContractFilter({ From 646b89d911ca80b87b004552c3840b5cda317911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Thu, 11 Jun 2026 11:58:22 +0200 Subject: [PATCH 09/13] test(core-acs-reader): finish working on tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- core/acs-reader/src/__test__/reader.test.ts | 677 +++++++++++++------- core/acs-reader/src/reader/reader.ts | 2 +- 2 files changed, 450 insertions(+), 229 deletions(-) diff --git a/core/acs-reader/src/__test__/reader.test.ts b/core/acs-reader/src/__test__/reader.test.ts index d835183e8..7a9b84abc 100644 --- a/core/acs-reader/src/__test__/reader.test.ts +++ b/core/acs-reader/src/__test__/reader.test.ts @@ -22,81 +22,88 @@ const { mockCacheCollection, MockACSCacheCollection } = vi.hoisted(() => { const { mockService, MockAcsService } = vi.hoisted(() => { const getActiveContracts = vi.fn() + const getPaginatedActiveContracts = vi.fn() const mockService = { getActiveContracts, + getPaginatedActiveContracts, } const MockAcsService = vi.fn( class { getActiveContracts = getActiveContracts + getPaginatedActiveContracts = getPaginatedActiveContracts } ) return { mockService, MockAcsService } }) -vi.mock('../cache/collection', () => ({ - ACSCacheCollection: MockACSCacheCollection, -})) +vi.mock('../cache/collection', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ACSCacheCollection: MockACSCacheCollection, + PaginatedACSCacheCollection: MockACSCacheCollection, + } +}) -vi.mock('../service', () => ({ - AcsService: MockAcsService, -})) +vi.mock('../service', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + AcsService: MockAcsService, + } +}) const ledgerProvider = vi.hoisted(() => ({ request: vi.fn(), })) -describe.skip('reader', () => { +describe('reader', () => { let reader: ACSReader - const mockActiveContracts = [ - { - workflowId: 'wf1', - contractEntry: { - JsActiveContract: { - createdEvent: { - contractId: 'contract-1', - templateId: 'template1', - contractKey: null, - createArguments: {}, - createdAt: '2024-01-01T00:00:00Z', - signatories: ['party1'], - observers: [], - }, - synchronizerId: 'sync1', - reassignmentCounter: 0, - }, - }, - }, - { - workflowId: 'wf2', - contractEntry: { - JsActiveContract: { - createdEvent: { - contractId: 'contract-2', - templateId: 'template2', - contractKey: null, - createArguments: {}, - createdAt: '2024-01-01T00:00:00Z', - signatories: ['party2'], - observers: [], - }, - synchronizerId: 'sync2', - reassignmentCounter: 0, + const createMockContract = (id: string, party: string, syncId: string) => ({ + workflowId: `wf-${id}`, + contractEntry: { + JsActiveContract: { + createdEvent: { + contractId: `contract-${id}`, + templateId: `template${id}`, + contractKey: null, + createArguments: {}, + createdAt: '2024-01-01T00:00:00Z', + signatories: [party], + observers: [], }, + synchronizerId: syncId, + reassignmentCounter: 0, }, }, + }) + + const mockActiveContracts = [ + createMockContract('1', 'party1', 'sync1'), + createMockContract('2', 'party2', 'sync2'), ] + const expectLedgerEndCalled = () => { + expect(ledgerProvider.request).toHaveBeenCalledWith({ + method: 'ledgerApi', + params: { resource: '/v2/state/ledger-end', requestMethod: 'get' }, + }) + } + beforeEach(() => { vi.clearAllMocks() - mockService.getActiveContracts.mockResolvedValue(mockActiveContracts) + mockService.getPaginatedActiveContracts.mockResolvedValue({ + activeContracts: mockActiveContracts, + activeAtOffset: 100, + nextPageToken: '', + }) mockCacheCollection.readFromCache.mockResolvedValue(mockActiveContracts) ledgerProvider.request.mockResolvedValue({ offset: 1000 }) - reader = new ACSReader(ledgerProvider) }) @@ -129,7 +136,6 @@ describe.skip('reader', () => { parties: ['party1'], templateIds: ['template1'], } - const result = await reader.raw.read(options) expect(mockService.getActiveContracts).toHaveBeenCalledWith(options) @@ -139,98 +145,63 @@ describe.skip('reader', () => { it('should resolve offset when not provided', async () => { ledgerProvider.request.mockResolvedValue({ offset: 500 }) - - const options = { + await reader.raw.read({ parties: ['party1'], templateIds: ['template1'], - } - - await reader.raw.read(options) - - expect(ledgerProvider.request).toHaveBeenCalledWith({ - method: 'ledgerApi', - params: { - resource: '/v2/state/ledger-end', - requestMethod: 'get', - }, }) - expect(mockService.getActiveContracts).toHaveBeenCalledWith({ - ...options, - offset: 500, - }) + expectLedgerEndCalled() + expect(mockService.getActiveContracts).toHaveBeenCalledWith( + expect.objectContaining({ offset: 500 }) + ) }) it('should handle empty results', async () => { mockService.getActiveContracts.mockResolvedValue([]) - - const result = await reader.raw.read({ - offset: 100, - parties: ['party1'], - }) - - expect(result).toEqual([]) + expect( + await reader.raw.read({ offset: 100, parties: ['party1'] }) + ).toEqual([]) }) }) describe('raw.readJsContracts', () => { it('should read and transform to JS contracts', async () => { - const options = { + const result = await reader.raw.readJsContracts({ offset: 100, parties: ['party1'], templateIds: ['template1'], - } - - const result = await reader.raw.readJsContracts(options) + }) - expect(mockService.getActiveContracts).toHaveBeenCalledWith(options) expect(result).toHaveLength(2) - expect(result[0]).toEqual({ + expect(result[0]).toMatchObject({ contractId: 'contract-1', templateId: 'template1', - contractKey: null, - createArguments: {}, - createdAt: '2024-01-01T00:00:00Z', - signatories: ['party1'], - observers: [], synchronizerId: 'sync1', }) }) it('should filter out contracts without JsActiveContract', async () => { - const mixedContracts = [ + mockService.getActiveContracts.mockResolvedValue([ ...mockActiveContracts, - { - workflowId: 'wf3', - contractEntry: null, - }, - { - workflowId: 'wf4', - contractEntry: { - OtherType: {}, - }, - }, - ] - - mockService.getActiveContracts.mockResolvedValue(mixedContracts) + { workflowId: 'wf3', contractEntry: null }, + { workflowId: 'wf4', contractEntry: { OtherType: {} } }, + ]) const result = await reader.raw.readJsContracts({ offset: 100, parties: ['party1'], }) - expect(result).toHaveLength(2) }) it('should return empty array when no contracts', async () => { mockService.getActiveContracts.mockResolvedValue([]) - - const result = await reader.raw.readJsContracts({ - offset: 100, - parties: ['party1'], - }) - - expect(result).toEqual([]) + expect( + await reader.raw.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + ).toEqual([]) }) }) @@ -241,7 +212,6 @@ describe.skip('reader', () => { parties: ['party1'], templateIds: ['template1'], } - const result = await reader.read(options) expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( @@ -253,62 +223,43 @@ describe.skip('reader', () => { it('should resolve offset when not provided', async () => { ledgerProvider.request.mockResolvedValue({ offset: 750 }) - - const options = { + await reader.read({ parties: ['party1'], templateIds: ['template1'], - } - - await reader.read(options) - - expect(ledgerProvider.request).toHaveBeenCalledWith({ - method: 'ledgerApi', - params: { - resource: '/v2/state/ledger-end', - requestMethod: 'get', - }, }) - expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith({ - ...options, - offset: 750, - }) + expectLedgerEndCalled() + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + expect.objectContaining({ offset: 750 }) + ) }) it('should handle empty cache results', async () => { mockCacheCollection.readFromCache.mockResolvedValue([]) - - const result = await reader.read({ - offset: 100, - parties: ['party1'], - }) - - expect(result).toEqual([]) - }) - - it('should work with multiple parties and templates', async () => { - const options = { - offset: 200, - parties: ['party1', 'party2'], - templateIds: ['template1', 'template2'], - } - - await reader.read(options) - - expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( - options - ) + expect( + await reader.read({ offset: 100, parties: ['party1'] }) + ).toEqual([]) }) - it('should work with interface IDs', async () => { - const options = { - offset: 200, - parties: ['party1'], - interfaceIds: ['interface1'], - } - + it.each([ + [ + 'multiple parties and templates', + { + offset: 200, + parties: ['party1', 'party2'], + templateIds: ['template1', 'template2'], + }, + ], + [ + 'interface IDs', + { + offset: 200, + parties: ['party1'], + interfaceIds: ['interface1'], + }, + ], + ])('should work with %s', async (_, options) => { await reader.read(options) - expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( options ) @@ -317,138 +268,408 @@ describe.skip('reader', () => { describe('readJsContracts', () => { it('should read from cache and transform to JS contracts', async () => { - const options = { + const result = await reader.readJsContracts({ offset: 100, parties: ['party1'], templateIds: ['template1'], - } - - const result = await reader.readJsContracts(options) + }) - expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( - options - ) expect(result).toHaveLength(2) - expect(result[0]).toEqual({ + expect(result[0]).toMatchObject({ contractId: 'contract-1', - templateId: 'template1', - contractKey: null, - createArguments: {}, - createdAt: '2024-01-01T00:00:00Z', - signatories: ['party1'], - observers: [], synchronizerId: 'sync1', }) - expect(result[1]).toEqual({ + expect(result[1]).toMatchObject({ contractId: 'contract-2', - templateId: 'template2', - contractKey: null, - createArguments: {}, - createdAt: '2024-01-01T00:00:00Z', - signatories: ['party2'], - observers: [], synchronizerId: 'sync2', }) }) it('should filter out contracts without JsActiveContract', async () => { - const mixedContracts = [ + mockCacheCollection.readFromCache.mockResolvedValue([ mockActiveContracts[0], - { - workflowId: 'wf3', - contractEntry: null, - }, + { workflowId: 'wf3', contractEntry: null }, mockActiveContracts[1], - { - workflowId: 'wf4', - contractEntry: { - OtherType: {}, - }, - }, - ] - - mockCacheCollection.readFromCache.mockResolvedValue(mixedContracts) + { workflowId: 'wf4', contractEntry: { OtherType: {} } }, + ]) const result = await reader.readJsContracts({ offset: 100, parties: ['party1'], }) - expect(result).toHaveLength(2) - expect(result[0].contractId).toBe('contract-1') - expect(result[1].contractId).toBe('contract-2') + expect(result.map((c) => c.contractId)).toEqual([ + 'contract-1', + 'contract-2', + ]) }) it('should return empty array when cache is empty', async () => { mockCacheCollection.readFromCache.mockResolvedValue([]) + expect( + await reader.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + ).toEqual([]) + }) - const result = await reader.readJsContracts({ - offset: 100, + it('should resolve offset before reading from cache', async () => { + ledgerProvider.request.mockResolvedValue({ offset: 999 }) + await reader.readJsContracts({ parties: ['party1'], + templateIds: ['template1'], }) - expect(result).toEqual([]) + expectLedgerEndCalled() + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + expect.objectContaining({ offset: 999 }) + ) }) + }) - it('should preserve synchronizerId in output', async () => { - const result = await reader.readJsContracts({ - offset: 100, - parties: ['party1'], + describe('error handling', () => { + it.each([ + [ + 'service', + () => + mockService.getActiveContracts.mockRejectedValue( + new Error('Service error') + ), + () => reader.raw.read({ offset: 100, parties: ['party1'] }), + 'Service error', + ], + [ + 'cache', + () => + mockCacheCollection.readFromCache.mockRejectedValue( + new Error('Cache error') + ), + () => reader.read({ offset: 100, parties: ['party1'] }), + 'Cache error', + ], + [ + 'ledger-end', + () => + ledgerProvider.request.mockRejectedValue( + new Error('Ledger error') + ), + () => reader.read({ parties: ['party1'] }), + 'Ledger error', + ], + ])( + 'should propagate errors from %s', + async (_, setupError, action, expectedError) => { + setupError() + await expect(action()).rejects.toThrow(expectedError) + } + ) + }) + + describe('paginated', () => { + const createPagedMocks = (pages: number = 1) => { + if (pages === 1) { + return { + activeContracts: mockActiveContracts, + activeAtOffset: 100, + nextPageToken: '', + } + } + return Array.from({ length: pages }, (_, i) => ({ + activeContracts: [mockActiveContracts[i]], + activeAtOffset: 100 * (i + 1), + nextPageToken: i < pages - 1 ? `page${i + 2}` : '', + })) + } + + describe('raw.read', () => { + it('should read paginated active contracts directly without cache', async () => { + mockService.getPaginatedActiveContracts.mockResolvedValue( + createPagedMocks() + ) + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + + const result = await reader.paginated.raw.read(options) + + expect( + mockService.getPaginatedActiveContracts + ).toHaveBeenCalledWith(options) + expect(result).toEqual(mockActiveContracts) + expect(mockCacheCollection.readFromCache).not.toHaveBeenCalled() }) - expect(result[0].synchronizerId).toBe('sync1') - expect(result[1].synchronizerId).toBe('sync2') - }) + it('should handle multiple pages when continueUntilCompletion is true', async () => { + mockService.getPaginatedActiveContracts.mockResolvedValue( + createPagedMocks(2) + ) - it('should resolve offset before reading from cache', async () => { - ledgerProvider.request.mockResolvedValue({ offset: 999 }) + const result = await reader.paginated.raw.read({ + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + }) - await reader.readJsContracts({ - parties: ['party1'], - templateIds: ['template1'], + expect(result).toEqual(mockActiveContracts) }) - expect(ledgerProvider.request).toHaveBeenCalledWith({ - method: 'ledgerApi', - params: { - resource: '/v2/state/ledger-end', - requestMethod: 'get', - }, + it('should resolve offset when not provided', async () => { + ledgerProvider.request.mockResolvedValue({ offset: 500 }) + await reader.paginated.raw.read({ + parties: ['party1'], + templateIds: ['template1'], + }) + + expectLedgerEndCalled() + expect( + mockService.getPaginatedActiveContracts + ).toHaveBeenCalledWith(expect.objectContaining({ offset: 500 })) }) - expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith({ - parties: ['party1'], - templateIds: ['template1'], - offset: 999, + it('should handle empty page results', async () => { + mockService.getPaginatedActiveContracts.mockResolvedValue({ + activeContracts: [], + activeAtOffset: 100, + nextPageToken: '', + }) + expect( + await reader.paginated.raw.read({ + offset: 100, + parties: ['party1'], + }) + ).toEqual([]) + }) + + it.each([ + ['pageToken', { pageToken: 'customToken' }], + ['maxPageSize', { maxPageSize: 50 }], + ])('should include %s when provided', async (_, extraOptions) => { + const options = { + offset: 100, + parties: ['party1'], + ...extraOptions, + } + await reader.paginated.raw.read(options) + expect( + mockService.getPaginatedActiveContracts + ).toHaveBeenCalledWith(options) }) }) - }) - describe('error handling', () => { - it('should propagate errors from service', async () => { - const error = new Error('Service error') - mockService.getActiveContracts.mockRejectedValue(error) + describe('raw.readJsContracts', () => { + it('should read and transform paginated contracts to JS contracts', async () => { + mockService.getPaginatedActiveContracts.mockResolvedValue( + createPagedMocks() + ) + + const result = await reader.paginated.raw.readJsContracts({ + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + }) + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + contractId: 'contract-1', + synchronizerId: 'sync1', + }) + }) + + it('should handle multiple pages and flatten results', async () => { + mockService.getPaginatedActiveContracts.mockResolvedValue( + createPagedMocks(2) + ) + + const result = await reader.paginated.raw.readJsContracts({ + offset: 100, + parties: ['party1'], + continueUntilCompletion: true, + }) + + expect(result).toHaveLength(2) + expect(result.map((c) => c.contractId)).toEqual([ + 'contract-1', + 'contract-2', + ]) + }) - await expect( - reader.raw.read({ offset: 100, parties: ['party1'] }) - ).rejects.toThrow('Service error') + it('should filter out contracts without JsActiveContract', async () => { + mockService.getPaginatedActiveContracts.mockResolvedValue({ + activeContracts: [ + ...mockActiveContracts, + { workflowId: 'wf3', contractEntry: null }, + ], + activeAtOffset: 100, + nextPageToken: '', + }) + + const result = await reader.paginated.raw.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + expect(result).toHaveLength(2) + }) }) - it('should propagate errors from cache', async () => { - const error = new Error('Cache error') - mockCacheCollection.readFromCache.mockRejectedValue(error) + describe('read', () => { + it('should read paginated active contracts from cache', async () => { + const options = { + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + } + const result = await reader.paginated.read(options) + + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + options + ) + expect(result).toEqual(mockActiveContracts) + expect( + mockService.getPaginatedActiveContracts + ).not.toHaveBeenCalled() + }) + + it('should resolve offset when not provided', async () => { + ledgerProvider.request.mockResolvedValue({ offset: 750 }) + await reader.paginated.read({ + parties: ['party1'], + templateIds: ['template1'], + }) + + expectLedgerEndCalled() + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + expect.objectContaining({ offset: 750 }) + ) + }) - await expect( - reader.read({ offset: 100, parties: ['party1'] }) - ).rejects.toThrow('Cache error') + it.each([ + ['pageToken', { pageToken: 'token123' }], + ['maxPageSize', { maxPageSize: 100 }], + ])('should work with %s option', async (_, extraOptions) => { + const options = { + offset: 200, + parties: ['party1'], + ...extraOptions, + } + await reader.paginated.read(options) + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + options + ) + }) + + it('should handle empty cache results', async () => { + mockCacheCollection.readFromCache.mockResolvedValue([]) + expect( + await reader.paginated.read({ + offset: 100, + parties: ['party1'], + }) + ).toEqual([]) + }) }) - it('should propagate errors from ledger-end request', async () => { - const error = new Error('Ledger error') - ledgerProvider.request.mockRejectedValue(error) + describe('readJsContracts', () => { + it('should read paginated from cache and transform to JS contracts', async () => { + const result = await reader.paginated.readJsContracts({ + offset: 100, + parties: ['party1'], + templateIds: ['template1'], + }) + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + contractId: 'contract-1', + synchronizerId: 'sync1', + }) + }) + + it('should filter out contracts without JsActiveContract from cache', async () => { + mockCacheCollection.readFromCache.mockResolvedValue([ + mockActiveContracts[0], + { workflowId: 'wf3', contractEntry: null }, + mockActiveContracts[1], + ]) + + const result = await reader.paginated.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + expect(result.map((c) => c.contractId)).toEqual([ + 'contract-1', + 'contract-2', + ]) + }) + + it('should resolve offset before reading from cache', async () => { + ledgerProvider.request.mockResolvedValue({ offset: 999 }) + await reader.paginated.readJsContracts({ + parties: ['party1'], + templateIds: ['template1'], + }) + + expectLedgerEndCalled() + expect(mockCacheCollection.readFromCache).toHaveBeenCalledWith( + expect.objectContaining({ offset: 999 }) + ) + }) + + it('should return empty array when cache is empty', async () => { + mockCacheCollection.readFromCache.mockResolvedValue([]) + expect( + await reader.paginated.readJsContracts({ + offset: 100, + parties: ['party1'], + }) + ).toEqual([]) + }) + }) - await expect(reader.read({ parties: ['party1'] })).rejects.toThrow( - 'Ledger error' + describe('error handling', () => { + it.each([ + [ + 'paginated service', + () => + mockService.getPaginatedActiveContracts.mockRejectedValue( + new Error('Paginated service error') + ), + () => + reader.paginated.raw.read({ + offset: 100, + parties: ['party1'], + }), + 'Paginated service error', + ], + [ + 'paginated cache', + () => + mockCacheCollection.readFromCache.mockRejectedValue( + new Error('Paginated cache error') + ), + () => + reader.paginated.read({ + offset: 100, + parties: ['party1'], + }), + 'Paginated cache error', + ], + [ + 'ledger-end in paginated mode', + () => + ledgerProvider.request.mockRejectedValue( + new Error('Ledger error in paginated') + ), + () => reader.paginated.read({ parties: ['party1'] }), + 'Ledger error in paginated', + ], + ])( + 'should propagate errors from %s', + async (_, setupError, action, expectedError) => { + setupError() + await expect(action()).rejects.toThrow(expectedError) + } ) }) }) diff --git a/core/acs-reader/src/reader/reader.ts b/core/acs-reader/src/reader/reader.ts index 0f3cc3eb9..47423136a 100644 --- a/core/acs-reader/src/reader/reader.ts +++ b/core/acs-reader/src/reader/reader.ts @@ -26,7 +26,7 @@ class RawReader extends BaseReader { super(ledger, cacheOptions) } - public async read( + public override async read( options: AcsOptions ): Promise { const resolvedOptions = await this.resolveAcsOptions(options) From cc6cfb6226a29a2cb9d6dedd1a813546f963453f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Tue, 16 Jun 2026 11:44:08 +0200 Subject: [PATCH 10/13] refactor(core-acs-reader): remove redundant flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- core/acs-reader/package.json | 4 ++-- sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts | 9 ++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/core/acs-reader/package.json b/core/acs-reader/package.json index 4c0a2f42c..c9a924023 100644 --- a/core/acs-reader/package.json +++ b/core/acs-reader/package.json @@ -22,8 +22,8 @@ "dev": "tsup --watch --onSuccess \"tsc\"", "clean": "tsc -b --clean; rm -rf dist", "flatpack": "yarn pack --out \"$FLATPACK_OUTDIR\"", - "test": "vitest run --project node --project browser --passWithNoTests", - "test:coverage": "vitest run --project node --project browser --coverage --passWithNoTests" + "test": "vitest run --project node --project browser", + "test:coverage": "vitest run --project node --project browser --coverage" }, "devDependencies": { "@types/node": "^25.0.10", diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 43ae6c662..2a32cfdd9 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -182,14 +182,9 @@ export class LedgerNamespace { readRaw: async ( options: AcsRequestOptions ): Promise> => { - const resolvedOptions = await this.resolveAcsOptions(options) + this.sdkContext.logger.debug(options, `Querying acs with options:`) - this.sdkContext.logger.debug( - resolvedOptions, - `Querying acs with options:` - ) - - return await this.acsReader.raw.read(resolvedOptions) + return await this.acsReader.raw.read(options) }, /** * Queries the ACS and filters for JsActiveContracts From cc8d5fd5f4d4c66b86a8d057dc0530b87b9f08ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Tue, 16 Jun 2026 11:45:03 +0200 Subject: [PATCH 11/13] refactor(wallet-sdk): remove redundant method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- .../src/wallet/namespace/ledger/namespace.ts | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts index 2a32cfdd9..06002fef4 100644 --- a/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts +++ b/sdk/wallet-sdk/src/wallet/namespace/ledger/namespace.ts @@ -10,7 +10,7 @@ import { Ops } from '@canton-network/core-provider-ledger' import { DarNamespace } from './dar/client.js' import { InternalLedgerNamespace } from './internal/index.js' import { PreparedTransactionNamespace } from './hash/namespace.js' -import { AcsOptions, ACSReader } from '@canton-network/core-acs-reader' +import { ACSReader } from '@canton-network/core-acs-reader' export class LedgerNamespace { public readonly dar: DarNamespace @@ -213,27 +213,4 @@ export class LedgerNamespace { }) }, } - - /** - * @deprecated - */ - private async resolveAcsOptions( - options: AcsRequestOptions - ): Promise { - const offset = - options.offset ?? - ( - await this.sdkContext.ledgerProvider.request( - { - method: 'ledgerApi', - params: { - resource: '/v2/state/ledger-end', - requestMethod: 'get', - }, - } - ) - ).offset! - - return { ...options, offset } - } } From e2f83abbdc06169c1ccee3d0bb627efd5c845a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Tue, 16 Jun 2026 13:47:09 +0200 Subject: [PATCH 12/13] test(core-acs-reader): add additional test assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- core/acs-reader/src/__test__/cache/collection.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/acs-reader/src/__test__/cache/collection.test.ts b/core/acs-reader/src/__test__/cache/collection.test.ts index ff3b37e4b..d537d54fb 100644 --- a/core/acs-reader/src/__test__/cache/collection.test.ts +++ b/core/acs-reader/src/__test__/cache/collection.test.ts @@ -114,6 +114,15 @@ describe('cache collection', () => { expect(MockACSCache).toHaveBeenCalledTimes(6) expect(mockCache.update).toHaveBeenCalledTimes(6) expect(mockCache.calculateAt).toHaveBeenCalledTimes(6) + for (let i = 0; i < 6; ++i) { + expect(mockCache.update).toHaveBeenNthCalledWith( + i + 1, + options + ) + expect( + mockCache.calculateAt + ).toHaveBeenNthCalledWith(i + 1, options.offset) + } expect(result).toHaveLength(6) }) From 9961dee16710b6892ddc688b0ca15d343e502943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pi=C4=85tkowski?= Date: Wed, 17 Jun 2026 14:16:05 +0200 Subject: [PATCH 13/13] test(core-acs-reader): fix test build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mateusz Piątkowski --- .../src/__test__/cache/collection.test.ts | 4 +-- .../src/__test__/cache/item/item.test.ts | 33 ++++++++++++------- .../__test__/cache/item/paginatedItem.test.ts | 22 ++++++++++--- core/acs-reader/src/__test__/reader.test.ts | 8 ++--- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/core/acs-reader/src/__test__/cache/collection.test.ts b/core/acs-reader/src/__test__/cache/collection.test.ts index d537d54fb..68cdb4acc 100644 --- a/core/acs-reader/src/__test__/cache/collection.test.ts +++ b/core/acs-reader/src/__test__/cache/collection.test.ts @@ -26,10 +26,8 @@ const { mockCache, MockACSCache } = vi.hoisted(() => { return { mockCache, MockACSCache } }) -vi.mock('../../cache/item', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../cache/item', () => { return { - ...actual, ACSCache: MockACSCache, PaginatedACSCache: MockACSCache, } diff --git a/core/acs-reader/src/__test__/cache/item/item.test.ts b/core/acs-reader/src/__test__/cache/item/item.test.ts index 3ce7434f1..99aa38d77 100644 --- a/core/acs-reader/src/__test__/cache/item/item.test.ts +++ b/core/acs-reader/src/__test__/cache/item/item.test.ts @@ -4,23 +4,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ACSCache } from '../../../cache/item' -const { getActiveContracts, MockACSService } = vi.hoisted(() => { - const getActiveContracts = vi.fn() +const { getActiveContracts, MockACSService, mockBuildActiveContractFilter } = + vi.hoisted(() => { + const getActiveContracts = vi.fn() + const mockBuildActiveContractFilter = vi.fn((options) => ({ + filter: { filtersByParty: {} }, + verbose: false, + activeAtOffset: options.offset, + })) + + const MockACSService = vi.fn( + class { + getActiveContracts = getActiveContracts + } + ) - const MockACSService = vi.fn( - class { - getActiveContracts = getActiveContracts + return { + getActiveContracts, + MockACSService, + mockBuildActiveContractFilter, } - ) - - return { getActiveContracts, MockACSService } -}) + }) -vi.mock('../../../service.ts', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../service.ts', () => { return { - ...actual, AcsService: MockACSService, + buildActiveContractFilter: mockBuildActiveContractFilter, } }) diff --git a/core/acs-reader/src/__test__/cache/item/paginatedItem.test.ts b/core/acs-reader/src/__test__/cache/item/paginatedItem.test.ts index 4ff0e1fd1..fc5f36b0c 100644 --- a/core/acs-reader/src/__test__/cache/item/paginatedItem.test.ts +++ b/core/acs-reader/src/__test__/cache/item/paginatedItem.test.ts @@ -4,8 +4,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { PaginatedACSCache } from '../../../cache/item' -const { getPaginatedActiveContracts, MockACSService } = vi.hoisted(() => { +const { + getPaginatedActiveContracts, + MockACSService, + mockBuildActiveContractFilter, +} = vi.hoisted(() => { const getPaginatedActiveContracts = vi.fn() + const mockBuildActiveContractFilter = vi.fn((options) => ({ + filter: { filtersByParty: {} }, + verbose: false, + activeAtOffset: options.offset, + })) const MockACSService = vi.fn( class { @@ -13,14 +22,17 @@ const { getPaginatedActiveContracts, MockACSService } = vi.hoisted(() => { } ) - return { getPaginatedActiveContracts, MockACSService } + return { + getPaginatedActiveContracts, + MockACSService, + mockBuildActiveContractFilter, + } }) -vi.mock('../../../service.ts', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../service.ts', () => { return { - ...actual, AcsService: MockACSService, + buildActiveContractFilter: mockBuildActiveContractFilter, } }) diff --git a/core/acs-reader/src/__test__/reader.test.ts b/core/acs-reader/src/__test__/reader.test.ts index 7a9b84abc..8bcb84ead 100644 --- a/core/acs-reader/src/__test__/reader.test.ts +++ b/core/acs-reader/src/__test__/reader.test.ts @@ -39,19 +39,15 @@ const { mockService, MockAcsService } = vi.hoisted(() => { return { mockService, MockAcsService } }) -vi.mock('../cache/collection', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../cache/collection', () => { return { - ...actual, ACSCacheCollection: MockACSCacheCollection, PaginatedACSCacheCollection: MockACSCacheCollection, } }) -vi.mock('../service', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../service', () => { return { - ...actual, AcsService: MockAcsService, } })