diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index a9f86e3..0020408 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -392,6 +392,8 @@ impl AutoShareContract { /// Emits a `BatchProcessingCompleted` event for off-chain listeners. pub fn emit_batch_completed(env: Env, batch_id: BytesN<32>, processed_count: u32) { autoshare_logic::emit_batch_completed(env, batch_id, processed_count).unwrap(); + } + // ============================================================================ // Batch Notification Creation // ============================================================================ diff --git a/listener/src/services/batch-validation-service.ts b/listener/src/services/batch-validation-service.ts index a293e7c..220cdda 100644 --- a/listener/src/services/batch-validation-service.ts +++ b/listener/src/services/batch-validation-service.ts @@ -187,6 +187,7 @@ export class BatchValidationService { } else { invalidEntries.push({ index, + error: validation.error ?? '', error: validation.error ?? 'Invalid notification item', }); } diff --git a/listener/src/services/notification-retry-queue.test.ts b/listener/src/services/notification-retry-queue.test.ts index bc2b8b9..59c610a 100644 --- a/listener/src/services/notification-retry-queue.test.ts +++ b/listener/src/services/notification-retry-queue.test.ts @@ -1,6 +1,10 @@ import { xdr } from '@stellar/stellar-sdk'; import * as StellarSDK from '@stellar/stellar-sdk'; import { NotificationRetryQueue, NotificationFn } from './notification-retry-queue'; +import { + NotificationAnalyticsAggregator, + setNotificationAnalyticsAggregator, +} from './notification-analytics-aggregator'; jest.mock('../utils/logger', () => ({ __esModule: true, @@ -296,4 +300,64 @@ describe('NotificationRetryQueue', () => { queue.stop(); }); }); + + describe('analytics success counter (regression: no duplicate-counting)', () => { + let aggregator: NotificationAnalyticsAggregator; + + beforeEach(() => { + aggregator = new NotificationAnalyticsAggregator(); + setNotificationAnalyticsAggregator(aggregator); + }); + + afterEach(() => { + setNotificationAnalyticsAggregator(null); + }); + + it('increments success exactly once when an operation succeeds after multiple failed retries', async () => { + // Fails on attempts 1 and 2, succeeds on attempt 3. + let callCount = 0; + const notificationFn: NotificationFn = jest.fn().mockImplementation(async () => { + callCount++; + return callCount >= 3; + }); + + const queue = new NotificationRetryQueue(notificationFn, { + baseDelayMs: 100, + maxRetries: 5, + processIntervalMs: 50, + jitter: false, + }); + queue.start(); + + queue.enqueue(createMockEvent({ id: 'evt-multi-retry' }), mockContractConfig, 'req-regression'); + + const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; + + // Attempt 1 at t=100 (base delay), fails → retry recorded + jest.advanceTimersByTime(100); + await flush(); + // Attempt 2 at t=300 (100 + 100*2^1), fails → retry recorded + jest.advanceTimersByTime(200); + await flush(); + // Attempt 3 at t=700 (300 + 100*2^2), succeeds → success recorded + jest.advanceTimersByTime(400); + await flush(); + + queue.stop(); + + const snap = aggregator.snapshot(); + + // The notification fn was called exactly 3 times + expect(notificationFn).toHaveBeenCalledTimes(3); + + // Success must be exactly 1 — not 0 (missing) and not >1 (duplicate) + expect(snap.overall.success).toBe(1); + + // Three retry-attempt events were emitted (one per call before success is known) + expect(snap.overall.retry).toBe(3); + + // No failure outcome — the operation ultimately succeeded + expect(snap.overall.failure).toBe(0); + }); + }); }); diff --git a/listener/src/services/notification-retry-queue.ts b/listener/src/services/notification-retry-queue.ts index 7114e20..6c66e9b 100644 --- a/listener/src/services/notification-retry-queue.ts +++ b/listener/src/services/notification-retry-queue.ts @@ -187,6 +187,13 @@ export class NotificationRetryQueue { if (success) { this.queuedFingerprints.delete(fingerprint); + this.analytics?.record({ + notificationType: NotificationType.DISCORD, + contractAddress: item.contractConfig.address, + outcome: 'success', + durationMs: Date.now() - retryStart, + timestamp: Date.now(), + }); logger.info('Retry succeeded', { requestId: item.requestId, eventId: item.event.id,