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..8c7255a --- /dev/null +++ b/listener/src/__tests__/notification-delivery-lifecycle.e2e.test.ts @@ -0,0 +1,423 @@ +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 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 ledgerField = fields.find((f: any) => f.name === 'Ledger'); + 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 () => { + 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 () => { + // 1st call returns 503, 2nd returns 204 — simulates recover on retry. + fetchMock + .mockResolvedValueOnce(failedResponse(503, 'Service Unavailable')) + .mockResolvedValueOnce(okResponse()); + + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-22T00:00:00Z')); + + const retryQueue = new NotificationRetryQueue( + async () => { + const res = (await fetchMock()) as Partial; + return res.ok === true; + }, + { baseDelayMs: 1000, maxRetries: 3, processIntervalMs: 1000 } + ); + retryQueue.start(); + + // 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); + + // Schedule retry with the same callback + retryQueue.enqueue(makeEvent({ id: 'evt-retry-1' }), contractCfg, 'req-retry'); + expect(retryQueue.size()).toBe(1); + + // 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); + + retryQueue.stop(); + jest.useRealTimers(); + jest.setSystemTime(); + }); + + it('logs permanent failure after retry exhaustion', async () => { + fetchMock.mockResolvedValue(failedResponse(500, 'Internal Server Error')); + + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-22T00:00:00Z')); + + const retryQueue = new NotificationRetryQueue( + async () => { + const res = await fetchMock(); + return res.ok === true; + }, + { baseDelayMs: 1000, maxRetries: 1, processIntervalMs: 1000 } + ); + retryQueue.start(); + + const event = makeEvent({ id: 'evt-exhaust' }); + // 1 initial call fails + await fetchMock(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + retryQueue.enqueue(event, contractCfg); + + // Drive: 1 retry attempted, maxRetries=1 means it fails permanently + await jest.advanceTimersByTimeAsync(2500); + + // 1 initial + 1 retry = 2 total + 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(); + jest.setSystemTime(); + }); + }); + + // ========================================================================= + // 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)); + // getEventName returns the first non-null symbol, so 'task' is in the title + expect(body.embeds[0].title).toContain('task'); + // 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.scvU64(new StellarSDK.xdr.Uint64(42n)), + }); + + 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); + }); + }); +});