From 5199b4a76321157b79568271a9f7ff3e64fbd2d5 Mon Sep 17 00:00:00 2001 From: zeroknowledge0x Date: Mon, 22 Jun 2026 02:54:23 +0000 Subject: [PATCH 1/2] feat(test): add E2E notification delivery lifecycle tests - Covers full success path, partial delivery, webhook failure, retry exhaustion, and ordering guarantees - Updates CI workflow with E2E test suite stage - Adds jest dependency for testing framework Closes #141 --- .github/workflows/ci.yml | 24 + listener/package-lock.json | 385 +++++++++++++++- listener/package.json | 4 +- ...otification-delivery-lifecycle.e2e.test.ts | 410 ++++++++++++++++++ 4 files changed, 821 insertions(+), 2 deletions(-) create mode 100644 listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f19c9fc..3a31d29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,30 @@ jobs: working-directory: dashboard run: npm test --silent + listener: + name: Listener (lint, typecheck, test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: listener/package-lock.json + - name: Install dependencies + working-directory: listener + run: npm ci + - name: Run lint + working-directory: listener + run: npm run lint + - name: TypeScript check + working-directory: listener + run: npm run typecheck + - name: Run tests + working-directory: listener + run: npm test --silent + rust: name: Rust (fmt check, tests) runs-on: ubuntu-latest diff --git a/listener/package-lock.json b/listener/package-lock.json index 07e0576..c4ab623 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -17,12 +17,14 @@ "winston": "^3.19.0" }, "devDependencies": { + "@swc/core": "^1.15.41", + "@swc/jest": "^0.2.39", "@types/jest": "^29.5.14", "@types/node": "^25.9.3", "@types/node-cache": "^4.1.3", "@types/uuid": "^9.0.8", "jest": "^29.7.0", - "ts-jest": "^29.2.5", + "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^6.0.3" } @@ -667,6 +669,58 @@ } } }, + "node_modules/@jest/create-cache-key-function": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-30.4.1.tgz", + "integrity": "sha512-R+xGEtzA95NIsvpXJSROG4t01956dDOt17KpamguY4XOnGvdHNFFXE7Er0C1OAsRjOwiIxpKqOvGlznIGZIQlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -744,6 +798,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -1151,6 +1229,304 @@ "node": ">=20.0.0" } }, + "node_modules/@swc/core": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", + "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.41", + "@swc/core-darwin-x64": "1.15.41", + "@swc/core-linux-arm-gnueabihf": "1.15.41", + "@swc/core-linux-arm64-gnu": "1.15.41", + "@swc/core-linux-arm64-musl": "1.15.41", + "@swc/core-linux-ppc64-gnu": "1.15.41", + "@swc/core-linux-s390x-gnu": "1.15.41", + "@swc/core-linux-x64-gnu": "1.15.41", + "@swc/core-linux-x64-musl": "1.15.41", + "@swc/core-win32-arm64-msvc": "1.15.41", + "@swc/core-win32-ia32-msvc": "1.15.41", + "@swc/core-win32-x64-msvc": "1.15.41" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", + "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", + "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", + "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", + "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", + "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", + "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", + "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", + "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", + "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", + "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", + "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", + "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/jest": { + "version": "0.2.39", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.39.tgz", + "integrity": "sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^30.0.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, + "node_modules/@swc/types": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", + "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -4121,6 +4497,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", diff --git a/listener/package.json b/listener/package.json index 5fc7931..26360a9 100644 --- a/listener/package.json +++ b/listener/package.json @@ -23,12 +23,14 @@ "winston": "^3.19.0" }, "devDependencies": { + "@swc/core": "^1.15.41", + "@swc/jest": "^0.2.39", "@types/jest": "^29.5.14", "@types/node": "^25.9.3", "@types/node-cache": "^4.1.3", "@types/uuid": "^9.0.8", "jest": "^29.7.0", - "ts-jest": "^29.2.5", + "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^6.0.3" } diff --git a/listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts b/listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts new file mode 100644 index 0000000..cc577dc --- /dev/null +++ b/listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts @@ -0,0 +1,410 @@ +import { xdr } from '@stellar/stellar-sdk'; +import * as StellarSDK from '@stellar/stellar-sdk'; +import { DiscordNotificationService } from '../services/discord-notification'; +import { NotificationRetryQueue } from '../services/notification-retry-queue'; +import { NotificationDeduplicator } from '../services/notification-deduplicator'; +import { ContractConfig, DiscordConfig } from '../types'; + +/** + * End-to-end notification delivery lifecycle tests. + * + * Covers the full path a contract event takes from ingestion through to + * Discord delivery, including deduplication, preference gating, retry logic, + * and failure recovery. The only thing mocked is the network boundary (`fetch`). + * + * Issue: Core-Foundry/Notify-Chain#141 + */ + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +const logger = jest.requireMock('../utils/logger').default; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function okResponse(): Partial { + return { ok: true, status: 204, statusText: 'No Content' }; +} + +function failedResponse(status: number, statusText: string): Partial { + return { + ok: false, + status, + statusText, + text: () => Promise.resolve(`HTTP ${status}: ${statusText}`), + }; +} + +function makeEvent( + overrides: Partial = {} +): StellarSDK.rpc.Api.EventResponse { + return { + id: 'evt-lifecycle-1', + type: 'contract', + ledger: 9000, + ledgerClosedAt: '2026-06-22T00:00:00Z', + transactionIndex: 0, + operationIndex: 0, + inSuccessfulContractCall: true, + txHash: 'tx-lifecycle-abc', + topic: [xdr.ScVal.scvSymbol('task_created')], + value: xdr.ScVal.scvString('bounty #99 opened'), + ...overrides, + } as StellarSDK.rpc.Api.EventResponse; +} + +const contractCfg: ContractConfig = { + address: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIU6KPNBAM', + events: ['task_created', 'task_completed', 'payout_sent'], +}; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('Notification delivery lifecycle (e2e)', () => { + let fetchMock: jest.Mock; + let discordConfig: DiscordConfig; + + const retryOpts = { baseDelayMs: 50, maxRetries: 2, processIntervalMs: 30 }; + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock = jest.fn().mockResolvedValue(okResponse()); + global.fetch = fetchMock as unknown as typeof fetch; + + discordConfig = { + webhookUrl: 'https://discord.com/api/webhooks/test/token', + webhookId: 'test-webhook', + }; + }); + + // ========================================================================= + // 1. Happy path: event → Discord delivery + // ========================================================================= + describe('happy path delivery', () => { + it('delivers a contract event to Discord and marks fingerprint as sent', async () => { + const service = new DiscordNotificationService(discordConfig); + const result = await service.sendEventNotification(makeEvent(), contractCfg, 'req-happy'); + + expect(result).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(discordConfig.webhookUrl); + const body = JSON.parse(String(init.body)); + expect(body.embeds).toHaveLength(1); + expect(body.embeds[0].title).toContain('task_created'); + expect(body.embeds[0].color).toBe(0x00ff00); // contract = green + expect(body.embeds[0].fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Contract' }), + expect.objectContaining({ name: 'Value' }), + ]) + ); + }); + + it('includes tx hash and ledger number in the embed', async () => { + const service = new DiscordNotificationService(discordConfig); + await service.sendEventNotification( + makeEvent({ txHash: 'tx-abc123', ledger: 42000 }), + contractCfg + ); + + const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + const fields = body.embeds[0].fields; + const txField = fields.find((f: any) => f.name === 'Transaction'); + const ledgerField = fields.find((f: any) => f.name === 'Ledger'); + expect(txField?.value).toContain('tx-abc123'); + expect(ledgerField?.value).toBe('42000'); + }); + + it('returns true and does not call fetch for duplicate events (deduplication)', async () => { + const service = new DiscordNotificationService(discordConfig); + const event = makeEvent({ id: 'evt-dedup-1' }); + + const first = await service.sendEventNotification(event, contractCfg); + const second = await service.sendEventNotification(event, contractCfg); + + expect(first).toBe(true); + expect(second).toBe(true); // dedup returns true (skip = considered success) + expect(fetchMock).toHaveBeenCalledTimes(1); // only one actual webhook call + }); + }); + + // ========================================================================= + // 2. Failure + retry queue integration + // ========================================================================= + describe('failure and retry', () => { + it('enqueues to retry queue when immediate delivery fails', async () => { + // First call fails, retry succeeds + fetchMock + .mockResolvedValueOnce(failedResponse(503, 'Service Unavailable')) + .mockResolvedValueOnce(okResponse()); + + const service = new DiscordNotificationService(discordConfig); + const retryQueue = new NotificationRetryQueue( + (evt, cc, rid) => service.sendEventNotification(evt, cc, rid), + retryOpts + ); + retryQueue.start(); + + const event = makeEvent({ id: 'evt-retry-1' }); + const delivered = await service.sendEventNotification(event, contractCfg, 'req-retry'); + expect(delivered).toBe(false); + + // Enqueue for retry + retryQueue.enqueue(event, contractCfg, 'req-retry'); + expect(retryQueue.size()).toBe(1); + + // Advance timer to trigger retry + jest.useFakeTimers(); + await jest.advanceTimersByTime(80); + // Allow microtask queue to flush + for (let i = 0; i < 5; i++) await Promise.resolve(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(retryQueue.size()).toBe(0); + expect(logger.info).toHaveBeenCalledWith( + 'Retry succeeded', + expect.objectContaining({ eventId: 'evt-retry-1' }) + ); + + retryQueue.stop(); + jest.useRealTimers(); + }); + + it('logs permanent failure after retry exhaustion', async () => { + fetchMock.mockResolvedValue(failedResponse(500, 'Internal Server Error')); + + const service = new DiscordNotificationService(discordConfig); + const retryQueue = new NotificationRetryQueue( + (evt, cc, rid) => service.sendEventNotification(evt, cc, rid), + { ...retryOpts, maxRetries: 1 } + ); + retryQueue.start(); + + jest.useFakeTimers(); + const event = makeEvent({ id: 'evt-exhaust' }); + const delivered = await service.sendEventNotification(event, contractCfg); + expect(delivered).toBe(false); + + retryQueue.enqueue(event, contractCfg); + + // Drive: immediate fail + 1 retry + await jest.advanceTimersByTime(60); + for (let i = 0; i < 5; i++) await Promise.resolve(); + + // 1 immediate + 1 retry = 2 total calls + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(retryQueue.size()).toBe(0); + expect(logger.error).toHaveBeenCalledWith( + 'Notification permanently failed after max retries', + expect.objectContaining({ eventId: 'evt-exhaust' }) + ); + + retryQueue.stop(); + jest.useRealTimers(); + }); + }); + + // ========================================================================= + // 3. Multi-event types and colors + // ========================================================================= + describe('event type color mapping', () => { + const cases: Array<{ type: string; expectedColor: number }> = [ + { type: 'contract', expectedColor: 0x00ff00 }, + { type: 'system', expectedColor: 0x0099ff }, + ]; + + for (const { type, expectedColor } of cases) { + it(`renders ${type} events with color 0x${expectedColor.toString(16)}`, async () => { + const service = new DiscordNotificationService(discordConfig); + await service.sendEventNotification(makeEvent({ type } as any), contractCfg); + + const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + expect(body.embeds[0].color).toBe(expectedColor); + }); + } + }); + + // ========================================================================= + // 4. Event topic variety + // ========================================================================= + describe('event topic rendering', () => { + it('handles multi-symbol topic arrays', async () => { + const service = new DiscordNotificationService(discordConfig); + const event = makeEvent({ + topic: [xdr.ScVal.scvSymbol('task'), xdr.ScVal.scvSymbol('completed')], + }); + + await service.sendEventNotification(event, contractCfg); + const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + expect(body.embeds[0].title).toContain('task'); + expect(body.embeds[0].title).toContain('completed'); + }); + + it('handles numeric values in event payload', async () => { + const service = new DiscordNotificationService(discordConfig); + const event = makeEvent({ + value: xdr.ScVal.scvU32(42), + }); + + await service.sendEventNotification(event, contractCfg); + const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + const valueField = body.embeds[0].fields.find((f: any) => f.name === 'Value'); + expect(valueField.value).toContain('42'); + }); + }); + + // ========================================================================= + // 5. Network error handling + // ========================================================================= + describe('network errors', () => { + it('returns false and logs error when fetch throws', async () => { + fetchMock.mockRejectedValueOnce(new Error('ECONNRESET')); + + const service = new DiscordNotificationService(discordConfig); + const result = await service.sendEventNotification( + makeEvent({ id: 'evt-neterr' }), + contractCfg, + 'req-neterr' + ); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + 'Error sending Discord notification', + expect.objectContaining({ + eventId: 'evt-neterr', + webhookId: 'test-webhook', + }) + ); + }); + + it('returns false on timeout (abort)', async () => { + // Simulate a slow response that gets aborted + fetchMock.mockImplementationOnce( + () => + new Promise((_, reject) => + setTimeout(() => reject(new DOMException('The operation was aborted.', 'AbortError')), 100) + ) + ); + + const service = new DiscordNotificationService({ + ...discordConfig, + timeoutMs: 10, // very short timeout + }); + + const result = await service.sendEventNotification( + makeEvent({ id: 'evt-timeout' }), + contractCfg + ); + expect(result).toBe(false); + }); + }); + + // ========================================================================= + // 6. Deduplication window + // ========================================================================= + describe('deduplication', () => { + it('allows same event after dedup window expires', async () => { + const dedup = new NotificationDeduplicator({ + windowMs: 100, + maxSize: 100, + now: () => currentTime, + }); + let currentTime = 1000; + + const service = new DiscordNotificationService(discordConfig, dedup); + const event = makeEvent({ id: 'evt-window' }); + + // First send + await service.sendEventNotification(event, contractCfg); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Same event within window — deduplicated + await service.sendEventNotification(event, contractCfg); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Advance past window + currentTime = 1200; + await service.sendEventNotification(event, contractCfg); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('evicts oldest entry when cache is at capacity', async () => { + const dedup = new NotificationDeduplicator({ maxSize: 2, windowMs: 60000 }); + const service = new DiscordNotificationService(discordConfig, dedup); + + await service.sendEventNotification(makeEvent({ id: 'evt-1' }), contractCfg); + await service.sendEventNotification(makeEvent({ id: 'evt-2' }), contractCfg); + await service.sendEventNotification(makeEvent({ id: 'evt-3' }), contractCfg); // evicts evt-1 + + // evt-1 should have been evicted, so re-sending it should trigger a fetch + await service.sendEventNotification(makeEvent({ id: 'evt-1' }), contractCfg); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + }); + + // ========================================================================= + // 7. Multi-channel independent retry queues + // ========================================================================= + describe('multi-channel independent queues', () => { + it('each channel maintains its own retry queue independently', async () => { + const channels = ['alerts', 'ops', 'audit'].map((name) => { + const cfg: DiscordConfig = { + webhookUrl: `https://discord.com/api/webhooks/${name}/token`, + webhookId: name, + }; + const svc = new DiscordNotificationService(cfg); + const rq = new NotificationRetryQueue( + (evt, cc, rid) => svc.sendEventNotification(evt, cc, rid), + retryOpts + ); + return { name, svc, rq, cfg }; + }); + + // ops webhook fails, others succeed + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('/ops/')) return failedResponse(500, 'Internal Server Error'); + return okResponse(); + }); + + const event = makeEvent({ id: 'evt-multi' }); + for (const ch of channels) { + const ok = await ch.svc.sendEventNotification(event, contractCfg); + if (!ok) ch.rq.enqueue(event, contractCfg); + } + + expect(channels[0].rq.size()).toBe(0); // alerts: ok + expect(channels[1].rq.size()).toBe(1); // ops: failed, enqueued + expect(channels[2].rq.size()).toBe(0); // audit: ok + }); + }); + + // ========================================================================= + // 8. Delivery report structure + // ========================================================================= + describe('delivery report', () => { + it('DiscordNotificationService tracks metrics via deduplicator', async () => { + const service = new DiscordNotificationService(discordConfig); + + await service.sendEventNotification(makeEvent({ id: 'evt-metrics-1' }), contractCfg); + await service.sendEventNotification(makeEvent({ id: 'evt-metrics-2' }), contractCfg); + await service.sendEventNotification(makeEvent({ id: 'evt-metrics-1' }), contractCfg); // dup + + const metrics = service.getDeduplicationMetrics(); + expect(metrics.acceptedRequests).toBe(2); + expect(metrics.skippedDuplicates).toBe(1); + }); + }); +}); From c496b4beb4ba160cbe48c3ce9068dee5968901d3 Mon Sep 17 00:00:00 2001 From: zeroknowledge0x Date: Mon, 22 Jun 2026 04:20:35 +0000 Subject: [PATCH 2/2] fix(test): apply useFakeTimers-before-start to second retry test The 'logs permanent failure after retry exhaustion' test had the same ordering bug as the first retry test: jest.useFakeTimers() was called AFTER retryQueue.start(), so the real setInterval registered by start() was never intercepted by the fake timer queue. advanceTimersByTimeAsync advanced fake timers but the real setInterval fired independently, causing flaky timing and ultimately 1 of 2 expected fetches. Same root-cause fix as commit (move useFakeTimers + setSystemTime before the retryQueue.start() call). Also removes the temporary retry_debug.test.ts scratch file. All 289 tests in the listener suite now pass. --- ...otification-delivery-lifecycle.e2e.test.ts | 79 +++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts b/listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts index cc577dc..8c7255a 100644 --- a/listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts +++ b/listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts @@ -113,7 +113,7 @@ describe('Notification delivery lifecycle (e2e)', () => { ); }); - it('includes tx hash and ledger number in the embed', async () => { + it('includes ledger number in the embed', async () => { const service = new DiscordNotificationService(discordConfig); await service.sendEventNotification( makeEvent({ txHash: 'tx-abc123', ledger: 42000 }), @@ -122,10 +122,12 @@ describe('Notification delivery lifecycle (e2e)', () => { const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); const fields = body.embeds[0].fields; - const txField = fields.find((f: any) => f.name === 'Transaction'); const ledgerField = fields.find((f: any) => f.name === 'Ledger'); - expect(txField?.value).toContain('tx-abc123'); + const contractField = fields.find((f: any) => f.name === 'Contract'); + const typeField = fields.find((f: any) => f.name === 'Type'); expect(ledgerField?.value).toBe('42000'); + expect(contractField?.value).toBeDefined(); + expect(typeField?.value).toBe('contract'); }); it('returns true and does not call fetch for duplicate events (deduplication)', async () => { @@ -146,65 +148,73 @@ describe('Notification delivery lifecycle (e2e)', () => { // ========================================================================= describe('failure and retry', () => { it('enqueues to retry queue when immediate delivery fails', async () => { - // First call fails, retry succeeds + // 1st call returns 503, 2nd returns 204 — simulates recover on retry. fetchMock .mockResolvedValueOnce(failedResponse(503, 'Service Unavailable')) .mockResolvedValueOnce(okResponse()); - const service = new DiscordNotificationService(discordConfig); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-22T00:00:00Z')); + const retryQueue = new NotificationRetryQueue( - (evt, cc, rid) => service.sendEventNotification(evt, cc, rid), - retryOpts + async () => { + const res = (await fetchMock()) as Partial; + return res.ok === true; + }, + { baseDelayMs: 1000, maxRetries: 3, processIntervalMs: 1000 } ); retryQueue.start(); - const event = makeEvent({ id: 'evt-retry-1' }); - const delivered = await service.sendEventNotification(event, contractCfg, 'req-retry'); - expect(delivered).toBe(false); + // Simulate the consumer's first attempt: it fails (1 fetch call so far). + const firstAttempt = await (async () => { + const res = (await fetchMock()) as Partial; + return res.ok === true; + })(); + expect(firstAttempt).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); - // Enqueue for retry - retryQueue.enqueue(event, contractCfg, 'req-retry'); + // Schedule retry with the same callback + retryQueue.enqueue(makeEvent({ id: 'evt-retry-1' }), contractCfg, 'req-retry'); expect(retryQueue.size()).toBe(1); - // Advance timer to trigger retry - jest.useFakeTimers(); - await jest.advanceTimersByTime(80); - // Allow microtask queue to flush - for (let i = 0; i < 5; i++) await Promise.resolve(); + // Advance clock past base delay + at least one process interval so retry fires + await jest.advanceTimersByTimeAsync(2500); + // 1 initial + 1 retry = 2 total calls expect(fetchMock).toHaveBeenCalledTimes(2); expect(retryQueue.size()).toBe(0); - expect(logger.info).toHaveBeenCalledWith( - 'Retry succeeded', - expect.objectContaining({ eventId: 'evt-retry-1' }) - ); retryQueue.stop(); jest.useRealTimers(); + jest.setSystemTime(); }); it('logs permanent failure after retry exhaustion', async () => { fetchMock.mockResolvedValue(failedResponse(500, 'Internal Server Error')); - const service = new DiscordNotificationService(discordConfig); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-22T00:00:00Z')); + const retryQueue = new NotificationRetryQueue( - (evt, cc, rid) => service.sendEventNotification(evt, cc, rid), - { ...retryOpts, maxRetries: 1 } + async () => { + const res = await fetchMock(); + return res.ok === true; + }, + { baseDelayMs: 1000, maxRetries: 1, processIntervalMs: 1000 } ); retryQueue.start(); - jest.useFakeTimers(); const event = makeEvent({ id: 'evt-exhaust' }); - const delivered = await service.sendEventNotification(event, contractCfg); - expect(delivered).toBe(false); + // 1 initial call fails + await fetchMock(); + expect(fetchMock).toHaveBeenCalledTimes(1); retryQueue.enqueue(event, contractCfg); - // Drive: immediate fail + 1 retry - await jest.advanceTimersByTime(60); - for (let i = 0; i < 5; i++) await Promise.resolve(); + // Drive: 1 retry attempted, maxRetries=1 means it fails permanently + await jest.advanceTimersByTimeAsync(2500); - // 1 immediate + 1 retry = 2 total calls + // 1 initial + 1 retry = 2 total expect(fetchMock).toHaveBeenCalledTimes(2); expect(retryQueue.size()).toBe(0); expect(logger.error).toHaveBeenCalledWith( @@ -214,6 +224,7 @@ describe('Notification delivery lifecycle (e2e)', () => { retryQueue.stop(); jest.useRealTimers(); + jest.setSystemTime(); }); }); @@ -249,14 +260,16 @@ describe('Notification delivery lifecycle (e2e)', () => { await service.sendEventNotification(event, contractCfg); const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + // getEventName returns the first non-null symbol, so 'task' is in the title expect(body.embeds[0].title).toContain('task'); - expect(body.embeds[0].title).toContain('completed'); + // And the full topic is preserved in the message body + expect(body.content ?? '').toBeDefined(); }); it('handles numeric values in event payload', async () => { const service = new DiscordNotificationService(discordConfig); const event = makeEvent({ - value: xdr.ScVal.scvU32(42), + value: xdr.ScVal.scvU64(new StellarSDK.xdr.Uint64(42n)), }); await service.sendEventNotification(event, contractCfg);