|
| 1 | +import { IDL } from '@dfinity/candid'; |
| 2 | +import { Principal } from '@dfinity/principal'; |
| 3 | +import { type Agent } from './agent/api.ts'; |
| 4 | +import { RequestId } from './request_id.ts'; |
| 5 | +import { type LookupPathStatus, type LookupPathResultFound } from './certificate.ts'; |
| 6 | + |
| 7 | +// Track strategy creations and invocations |
| 8 | +const instantiatedStrategies: jest.Mock[] = []; |
| 9 | +jest.mock('./polling/strategy.ts', () => { |
| 10 | + return { |
| 11 | + defaultStrategy: jest.fn(() => { |
| 12 | + const fn = jest.fn(async () => { |
| 13 | + // no-op strategy used in tests |
| 14 | + }); |
| 15 | + instantiatedStrategies.push(fn); |
| 16 | + return fn; |
| 17 | + }), |
| 18 | + }; |
| 19 | +}); |
| 20 | + |
| 21 | +const textEncoder = new TextEncoder(); |
| 22 | +const textDecoder = new TextDecoder(); |
| 23 | + |
| 24 | +const statusesByRequestId = new Map<RequestId, string[]>(); |
| 25 | +const replyByRequestId = new Map<RequestId, Uint8Array>(); |
| 26 | + |
| 27 | +jest.mock('./certificate.ts', () => { |
| 28 | + return { |
| 29 | + lookupResultToBuffer: (res: { value: Uint8Array }) => res?.value, |
| 30 | + Certificate: { |
| 31 | + create: jest.fn(async () => { |
| 32 | + return { |
| 33 | + lookup_path: (path: [string, RequestId, string]): LookupPathResultFound => { |
| 34 | + // Path shape: ['request_status', requestIdBytes, 'status'|'reject_code'|'reject_message'|'error_code'|'reply'] |
| 35 | + const requestIdBytes = path[1]; |
| 36 | + const lastPathElement = path[path.length - 1] as string | Uint8Array; |
| 37 | + const lastPathElementStr = |
| 38 | + typeof lastPathElement === 'string' |
| 39 | + ? lastPathElement |
| 40 | + : textDecoder.decode(lastPathElement); |
| 41 | + |
| 42 | + if (lastPathElementStr === 'status') { |
| 43 | + const q = statusesByRequestId.get(requestIdBytes) ?? []; |
| 44 | + const current = q.length > 0 ? q.shift()! : 'replied'; |
| 45 | + statusesByRequestId.set(requestIdBytes, q); |
| 46 | + return { |
| 47 | + status: 'Found' as LookupPathStatus.Found, |
| 48 | + value: textEncoder.encode(current), |
| 49 | + }; |
| 50 | + } |
| 51 | + if (lastPathElementStr === 'reply') { |
| 52 | + return { |
| 53 | + status: 'Found' as LookupPathStatus.Found, |
| 54 | + value: replyByRequestId.get(requestIdBytes)!, |
| 55 | + }; |
| 56 | + } |
| 57 | + throw new Error(`Unexpected lastPathElementStr ${lastPathElementStr}`); |
| 58 | + }, |
| 59 | + } as const; |
| 60 | + }), |
| 61 | + }, |
| 62 | + }; |
| 63 | +}); |
| 64 | + |
| 65 | +describe('Actor default polling options are not reused across calls', () => { |
| 66 | + beforeEach(() => { |
| 67 | + instantiatedStrategies.length = 0; |
| 68 | + statusesByRequestId.clear(); |
| 69 | + replyByRequestId.clear(); |
| 70 | + jest.resetModules(); |
| 71 | + }); |
| 72 | + |
| 73 | + it('instantiates a fresh defaultStrategy per update call when using DEFAULT_POLLING_OPTIONS', async () => { |
| 74 | + const { Actor } = await import('./actor.ts'); |
| 75 | + const defaultStrategy = (await import('./polling/strategy.ts')).defaultStrategy as jest.Mock; |
| 76 | + |
| 77 | + const canisterId = Principal.anonymous(); |
| 78 | + |
| 79 | + const requestIdA = new Uint8Array([1, 2, 3]) as RequestId; |
| 80 | + const requestIdB = new Uint8Array([4, 5, 6]) as RequestId; |
| 81 | + statusesByRequestId.set(requestIdA, ['processing', 'replied']); |
| 82 | + statusesByRequestId.set(requestIdB, ['unknown', 'replied']); |
| 83 | + |
| 84 | + const expectedReplyArgA = IDL.encode([IDL.Text], ['okA']); |
| 85 | + const expectedReplyArgB = IDL.encode([IDL.Text], ['okB']); |
| 86 | + replyByRequestId.set(requestIdA, expectedReplyArgA); |
| 87 | + replyByRequestId.set(requestIdB, expectedReplyArgB); |
| 88 | + |
| 89 | + // Fake Agent that forces polling (202) and provides readState |
| 90 | + let callCount = 0; |
| 91 | + const fakeAgent = { |
| 92 | + rootKey: new Uint8Array([1]), |
| 93 | + call: async () => { |
| 94 | + const requestId = callCount === 0 ? requestIdA : requestIdB; |
| 95 | + callCount += 1; |
| 96 | + return { |
| 97 | + requestId, |
| 98 | + response: { status: 202 }, |
| 99 | + reply: replyByRequestId.get(requestId)!, |
| 100 | + requestDetails: {}, |
| 101 | + } as unknown as { |
| 102 | + requestId: Uint8Array; |
| 103 | + response: { status: number }; |
| 104 | + requestDetails: object; |
| 105 | + }; |
| 106 | + }, |
| 107 | + readState: async () => ({ certificate: new Uint8Array([0]) }), |
| 108 | + } as unknown as Agent; |
| 109 | + |
| 110 | + // Simple update method to trigger poll |
| 111 | + const actorInterface = () => |
| 112 | + IDL.Service({ |
| 113 | + upd: IDL.Func([IDL.Text], [IDL.Text]), |
| 114 | + }); |
| 115 | + |
| 116 | + const actor = Actor.createActor(actorInterface, { |
| 117 | + canisterId, |
| 118 | + // Critically, no pollingOptions override; Actor uses DEFAULT_POLLING_OPTIONS |
| 119 | + // which must not carry a pre-instantiated strategy |
| 120 | + agent: fakeAgent, |
| 121 | + }); |
| 122 | + |
| 123 | + const outA = await actor.upd('x'); |
| 124 | + const outB = await actor.upd('y'); |
| 125 | + expect(outA).toBe('okA'); |
| 126 | + expect(outB).toBe('okB'); |
| 127 | + |
| 128 | + // defaultStrategy should have been created once per call, not shared |
| 129 | + expect(defaultStrategy.mock.calls.length).toBe(2); |
| 130 | + // Each created strategy used at least once |
| 131 | + expect(instantiatedStrategies.length).toBe(2); |
| 132 | + expect(instantiatedStrategies[0].mock.calls.length).toBe(1); |
| 133 | + expect(instantiatedStrategies[1].mock.calls.length).toBe(1); |
| 134 | + }); |
| 135 | +}); |
0 commit comments