@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
257 lines • 13.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const TaskArcSSE_1 = require("../TaskArcSSE");
// ── Fake EventSource ─────────────────────────────────────────────────────────
class FakeEventSource {
constructor(url, opts) {
this.url = url;
this.opts = opts;
this.listeners = {};
this.closed = false;
FakeEventSource.instances.push(this);
}
addEventListener(type, fn) {
if (!this.listeners[type])
this.listeners[type] = [];
this.listeners[type].push(fn);
}
emit(type, event = {}) {
var _a;
for (const fn of (_a = this.listeners[type]) !== null && _a !== void 0 ? _a : [])
fn(event);
}
close() {
this.closed = true;
}
}
FakeEventSource.instances = [];
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Build a minimal TableProvenTxReq API object that EntityProvenTxReq can parse */
function makeReqApi(status, txid = 'txid1') {
const now = new Date();
return {
provenTxReqId: 1,
created_at: now,
updated_at: now,
txid,
rawTx: [1, 2, 3],
status,
history: JSON.stringify({}),
notify: JSON.stringify({ transactionIds: [1] }),
attempts: 0,
notified: false
};
}
function makeStorageWithReqs(reqApis) {
const sp = {
updateProvenTxReqDynamics: jest.fn().mockResolvedValue(undefined),
updateTransactionsStatus: jest.fn().mockResolvedValue(undefined)
};
return {
isStorageProvider: jest.fn().mockReturnValue(false),
findProvenTxReqs: jest.fn().mockResolvedValue(reqApis),
runAsStorageProvider: jest.fn(async (fn) => fn(sp))
};
}
function makeEmptyStorage() {
return makeStorageWithReqs([]);
}
/** Build a minimal Monitor stub */
function makeMonitor(overrides = {}) {
var _a, _b, _c, _d;
const storage = (_a = overrides.storageOverride) !== null && _a !== void 0 ? _a : makeEmptyStorage();
return {
options: {
callbackToken: overrides.callbackToken === null ? undefined : ((_b = overrides.callbackToken) !== null && _b !== void 0 ? _b : 'test-token'),
EventSourceClass: (_c = overrides.EventSourceClass) !== null && _c !== void 0 ? _c : FakeEventSource,
loadLastSSEEventId: overrides.loadLastSSEEventId,
saveLastSSEEventId: overrides.saveLastSSEEventId
},
services: {
options: { arcUrl: (_d = overrides.arcUrl) !== null && _d !== void 0 ? _d : 'https://arc.example.com' }
},
chain: 'test',
storage,
callOnTransactionStatusChanged: jest.fn(),
callOnProvenTransaction: jest.fn()
};
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('TaskArcadeSSE', () => {
beforeEach(() => {
FakeEventSource.instances = [];
jest.spyOn(console, 'log').mockImplementation(() => { });
});
afterEach(() => {
jest.restoreAllMocks();
});
// ── asyncSetup ─────────────────────────────────────────────────────────
describe('asyncSetup()', () => {
test('creates and connects SSE client when fully configured', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor());
await task.asyncSetup();
expect(task.sseClient).not.toBeNull();
expect(FakeEventSource.instances.length).toBe(1);
});
test('skips setup when callbackToken is absent', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor({ callbackToken: null }));
await task.asyncSetup();
expect(task.sseClient).toBeNull();
expect(FakeEventSource.instances.length).toBe(0);
});
test('skips setup when arcUrl is absent', async () => {
const monitor = makeMonitor({ arcUrl: '' });
monitor.services.options.arcUrl = '';
const task = new TaskArcSSE_1.TaskArcadeSSE(monitor);
await task.asyncSetup();
expect(task.sseClient).toBeNull();
});
test('skips setup when EventSourceClass is absent', async () => {
const monitor = makeMonitor();
monitor.options.EventSourceClass = undefined;
const task = new TaskArcSSE_1.TaskArcadeSSE(monitor);
await task.asyncSetup();
expect(task.sseClient).toBeNull();
});
test('passes loadLastSSEEventId result as lastEventId to client', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor({ loadLastSSEEventId: async () => '77' }));
await task.asyncSetup();
expect(task.sseClient.lastEventId).toBe('77');
});
test('continues setup when loadLastSSEEventId throws', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor({
loadLastSSEEventId: async () => {
throw new Error('db error');
}
}));
await expect(task.asyncSetup()).resolves.not.toThrow();
expect(task.sseClient).not.toBeNull();
});
});
// ── trigger ────────────────────────────────────────────────────────────
describe('trigger()', () => {
test('returns run=false when no pending events', () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor());
expect(task.trigger(Date.now()).run).toBe(false);
});
test('returns run=true after an SSE event is received', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor());
await task.asyncSetup();
const payload = { txid: 'aaaa', txStatus: 'MINED', timestamp: '' };
FakeEventSource.instances[0].emit('status', { data: JSON.stringify(payload) });
expect(task.trigger(Date.now()).run).toBe(true);
});
});
// ── runTask ────────────────────────────────────────────────────────────
describe('runTask()', () => {
test('returns empty string when there are no pending events', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor());
expect(await task.runTask()).toBe('');
});
test('drains pending events so trigger returns false afterward', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor());
await task.asyncSetup();
const payload = { txid: 'bbbb', txStatus: 'SEEN_ON_NETWORK', timestamp: '' };
FakeEventSource.instances[0].emit('status', { data: JSON.stringify(payload) });
FakeEventSource.instances[0].emit('status', { data: JSON.stringify(payload) });
expect(task.trigger(Date.now()).run).toBe(true);
await task.runTask();
expect(task.trigger(Date.now()).run).toBe(false);
});
test('calls callOnTransactionStatusChanged for each processed event', async () => {
const reqApi = makeReqApi('unsent', 'cccc');
const monitor = makeMonitor({ storageOverride: makeStorageWithReqs([reqApi]) });
const task = new TaskArcSSE_1.TaskArcadeSSE(monitor);
await task.asyncSetup();
FakeEventSource.instances[0].emit('status', {
data: JSON.stringify({ txid: 'cccc', txStatus: 'SEEN_ON_NETWORK', timestamp: '' })
});
await task.runTask();
expect(monitor.callOnTransactionStatusChanged).toHaveBeenCalledWith('cccc', 'SEEN_ON_NETWORK');
});
test('logs "No matching ProvenTxReq" when storage returns empty', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor());
await task.asyncSetup();
FakeEventSource.instances[0].emit('status', {
data: JSON.stringify({ txid: 'dddd', txStatus: 'MINED', timestamp: '' })
});
const log = await task.runTask();
expect(log).toContain('No matching ProvenTxReq');
});
});
// ── SSE status → ProvenTxReq transitions ──────────────────────────────
describe('SSE status → ProvenTxReq transitions', () => {
async function runWithStatus(txStatus, reqStatus) {
FakeEventSource.instances = [];
const reqApi = makeReqApi(reqStatus);
const storage = makeStorageWithReqs([reqApi]);
const monitor = makeMonitor({ storageOverride: storage });
const task = new TaskArcSSE_1.TaskArcadeSSE(monitor);
await task.asyncSetup();
FakeEventSource.instances[0].emit('status', {
data: JSON.stringify({ txid: reqApi.txid, txStatus, timestamp: '' })
});
const log = await task.runTask();
return { log, monitor };
}
test('SEEN_ON_NETWORK advances unsent req to unmined', async () => {
const { log } = await runWithStatus('SEEN_ON_NETWORK', 'unsent');
expect(log).toContain('=> unmined');
});
test('ACCEPTED_BY_NETWORK advances sending req to unmined', async () => {
const { log } = await runWithStatus('ACCEPTED_BY_NETWORK', 'sending');
expect(log).toContain('=> unmined');
});
test('SENT_TO_NETWORK advances callback req to unmined', async () => {
const { log } = await runWithStatus('SENT_TO_NETWORK', 'callback');
expect(log).toContain('=> unmined');
});
test('DOUBLE_SPEND_ATTEMPTED sets req to doubleSpend', async () => {
const { log } = await runWithStatus('DOUBLE_SPEND_ATTEMPTED', 'unmined');
expect(log).toContain('=> doubleSpend');
});
test('REJECTED sets req to invalid', async () => {
const { log } = await runWithStatus('REJECTED', 'unmined');
expect(log).toContain('=> invalid');
});
test('unknown status produces unhandled log entry', async () => {
const { log } = await runWithStatus('SOMETHING_NEW', 'unmined');
expect(log).toContain('unhandled status: SOMETHING_NEW');
});
test('does not process already-terminal reqs', async () => {
// ProvenTxReqTerminalStatus = ['completed', 'invalid', 'doubleSpend']
const terminalStatuses = ['completed', 'invalid', 'doubleSpend'];
for (const s of terminalStatuses) {
const { log } = await runWithStatus('MINED', s);
expect(log).toContain(`already terminal: ${s}`);
}
});
});
// ── fetchNow ───────────────────────────────────────────────────────────
describe('fetchNow()', () => {
test('returns 0 when sseClient is null', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor({ callbackToken: null }));
await task.asyncSetup();
expect(await task.fetchNow()).toBe(0);
});
test('returns 0 when sseClient is present', async () => {
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor());
await task.asyncSetup();
expect(await task.fetchNow()).toBe(0);
});
});
// ── saveLastSSEEventId persistence ────────────────────────────────────
describe('saveLastSSEEventId', () => {
test('is called when lastEventId changes on an incoming event', async () => {
const saveLastSSEEventId = jest.fn().mockResolvedValue(undefined);
const task = new TaskArcSSE_1.TaskArcadeSSE(makeMonitor({ saveLastSSEEventId }));
await task.asyncSetup();
FakeEventSource.instances[0].emit('status', {
data: JSON.stringify({ txid: 'eeee', txStatus: 'MINED', timestamp: '' }),
lastEventId: '55'
});
expect(saveLastSSEEventId).toHaveBeenCalledWith('55');
});
});
});
//# sourceMappingURL=TaskArcSSE.test.js.map