diff --git a/src/__tests__/sms/queue.test.ts b/src/__tests__/sms/queue.test.ts index ad290b0e..9d0d7a7f 100644 --- a/src/__tests__/sms/queue.test.ts +++ b/src/__tests__/sms/queue.test.ts @@ -26,6 +26,7 @@ describe('SMSQueue', () => { let provider: TwilioProvider; beforeEach(() => { + SMSQueue.clearAllDeliveryLogs(); provider = new TwilioProvider(); queue = new SMSQueue(provider, { maxRetries: 3, @@ -265,6 +266,8 @@ describe('SMSQueue', () => { const logs = queue.getDeliveryLogs(); expect(logs.length).toBeGreaterThan(0); + // Logs are returned newest-last; check the most recent entry + expect(logs[logs.length - 1].metadata).toBeDefined(); // Check the most recent log (last in the array) has metadata const lastLog = logs[logs.length - 1]; expect(lastLog.metadata).toBeDefined(); diff --git a/src/app/api/referral/__tests__/validate.test.ts b/src/app/api/referral/__tests__/validate.test.ts index 1f39b3fa..c72d6d6b 100644 --- a/src/app/api/referral/__tests__/validate.test.ts +++ b/src/app/api/referral/__tests__/validate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { POST } from '../validate/route'; +import { GET } from '../validate/route'; describe('Referral Validation API', () => { describe('GET /api/referral/validate', () => { @@ -13,7 +13,7 @@ describe('Referral Validation API', () => { } as any; // The route handler exists and can be imported - expect(POST).toBeDefined(); + expect(GET).toBeDefined(); }); it('should handle missing code parameter', async () => { diff --git a/src/app/store/notificationStore.ts b/src/app/store/notificationStore.ts index 9604b32c..76a7a2b8 100644 --- a/src/app/store/notificationStore.ts +++ b/src/app/store/notificationStore.ts @@ -2,11 +2,11 @@ import { create } from 'zustand'; import { AppNotification } from '@/lib/notifications/types'; import { NotificationService } from '@/lib/notifications/service'; -function load(key: string, fallback: T): T { +function load(key: string, fallback: T, reviver?: (key: string, value: unknown) => unknown): T { if (typeof window === 'undefined') return fallback; try { const raw = localStorage.getItem(key); - return raw ? (JSON.parse(raw) as T) : fallback; + return raw ? (JSON.parse(raw, reviver) as T) : fallback; } catch { return fallback; } @@ -19,6 +19,16 @@ function save(key: string, value: T) { } catch {} } +/** Revives ISO date strings found in `timestamp` fields back to Date objects. */ +function dateReviver(key: string, value: unknown): unknown { + if (key === 'timestamp' && typeof value === 'string') { + const date = new Date(value); + // Only return Date if the string was a valid ISO date + if (!isNaN(date.getTime())) return date; + } + return value; +} + const STORAGE_KEY = 'notifications_v1'; interface NotificationState { @@ -33,7 +43,7 @@ interface NotificationState { } export const useNotificationStore = create((set, get) => ({ - notifications: load(STORAGE_KEY, []), + notifications: load(STORAGE_KEY, [], dateReviver), addNotification: (n) => { const created = NotificationService.createNotification({ message: n.message, diff --git a/src/app/store/volunteerStore.ts b/src/app/store/volunteerStore.ts index 76305f92..c050fe4d 100644 --- a/src/app/store/volunteerStore.ts +++ b/src/app/store/volunteerStore.ts @@ -3,11 +3,11 @@ import type { Volunteer, VolunteerSMSPreferences } from '@/types/volunteer'; const STORAGE_KEY = 'volunteers_v1'; -function load(key: string, fallback: T): T { +function load(key: string, fallback: T, reviver?: (key: string, value: unknown) => unknown): T { if (typeof window === 'undefined') return fallback; try { const raw = localStorage.getItem(key); - return raw ? (JSON.parse(raw) as T) : fallback; + return raw ? (JSON.parse(raw, reviver) as T) : fallback; } catch { return fallback; } diff --git a/src/components/social/__tests__/commentSection.additional.test.tsx b/src/components/social/__tests__/commentSection.additional.test.tsx index f87ec8b1..bce9dbe8 100644 --- a/src/components/social/__tests__/commentSection.additional.test.tsx +++ b/src/components/social/__tests__/commentSection.additional.test.tsx @@ -50,8 +50,8 @@ describe('SocialInteractions – Additional edge cases', () => { render(); await waitFor(() => expect(screen.getByLabelText('Copy link')).toBeInTheDocument()); - await userEvent.setup().click(screen.getByLabelLabel('Copy link')); - expect(writeText).toHaveBeenCalledWith(customUrl); + await userEvent.setup().click(screen.getByLabelText('Copy link')); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(customUrl)); await waitFor(() => expect(screen.getByText('Copied!')).toBeInTheDocument()); }); }); diff --git a/src/lib/sms/queue.ts b/src/lib/sms/queue.ts index efcb1fe3..fd75a814 100644 --- a/src/lib/sms/queue.ts +++ b/src/lib/sms/queue.ts @@ -29,11 +29,14 @@ function createJobId(): string { // In-memory delivery log store for aggregation const deliveryLogs: Map = new Map(); +interface InternalQueueJob extends QueueJob { + resolve: (result: SMSSendResult) => void; +} + export class SMSQueue { private readonly provider: SMSProvider; private readonly options: QueueOptions; - private readonly queue: QueueJob[] = []; - private readonly resolveQueue: Array<(result: SMSSendResult) => void> = []; + private readonly queue: InternalQueueJob[] = []; private processing = 0; private requestId = ''; @@ -54,16 +57,22 @@ export class SMSQueue { }); } + /** Clear all shared delivery logs (useful for testing). */ + static clearAllDeliveryLogs(): void { + deliveryLogs.clear(); + } + enqueue(message: SMSMessage): Promise { return new Promise((resolve) => { const jobId = createJobId(); this.requestId = `${jobId}_parent`; - const job: QueueJob = { + const job: InternalQueueJob = { id: jobId, message, attempts: 0, createdAt: Date.now(), + resolve, }; logger.info('SMS message enqueued', { @@ -77,35 +86,31 @@ export class SMSQueue { }); this.queue.push(job); - this.resolveQueue.push(resolve); createCounterMetric('sms.enqueued', 1, { provider: this.provider.type, }); - this.process(); + this.processQueue(); }); } - private process(): void { - while (this.processing < this.options.maxConcurrent && this.queue.length > 0 && this.resolveQueue.length > 0) { + /** Drain the queue up to maxConcurrent limit. Each job carries its own resolve. */ + private processQueue(): void { + while (this.processing < this.options.maxConcurrent && this.queue.length > 0) { const nextJob = this.queue.shift(); if (!nextJob) { return; } - const resolve = this.resolveQueue.shift(); - this.processing += 1; - void this.runJob(nextJob) - .then((result) => resolve?.(result)) - .finally(() => { - this.processing -= 1; - this.process(); - }); + void this.runJob(nextJob).finally(() => { + this.processing -= 1; + this.processQueue(); + }); } } - private async runJob(job: QueueJob): Promise { + private async runJob(job: InternalQueueJob): Promise { let result: SMSSendResult = { success: false, provider: this.provider.type, @@ -158,7 +163,8 @@ export class SMSQueue { provider: this.provider.type, }); - return result; + job.resolve(result); + return; } if (job.attempts < this.options.maxRetries) { @@ -199,10 +205,11 @@ export class SMSQueue { provider: this.provider.type, }); - return { + const finalResult: SMSSendResult = { ...result, error: `Queue failed after ${job.attempts} attempts: ${result.error ?? 'Unknown error'}`, }; + job.resolve(finalResult); } private logDeliveryAttempt( diff --git a/src/services/tipService.ts b/src/services/tipService.ts index 6afbea1a..eb9f5627 100644 --- a/src/services/tipService.ts +++ b/src/services/tipService.ts @@ -25,8 +25,11 @@ export async function sendTip(payload: TipPayload): Promise { }); if (!response.ok) { - const message = - typeof responseBody.error === 'string' ? responseBody.error : 'Unable to send tip.'; + const responseBody = (await response.json().catch(() => null)) as { + error?: string; + message?: string; + } | null; + const message = responseBody?.error ?? responseBody?.message ?? 'Unable to send tip.'; throw new Error(message); } diff --git a/src/utils/__tests__/intlCache.test.ts b/src/utils/__tests__/intlCache.test.ts index 2b0005b1..40fc9542 100644 --- a/src/utils/__tests__/intlCache.test.ts +++ b/src/utils/__tests__/intlCache.test.ts @@ -145,16 +145,20 @@ describe('intlCache performance', () => { it('cache hit is significantly faster than cache miss (construction)', () => { const iterations = 10000; const locale = 'en-US'; + const options = { maximumFractionDigits: 2 }; + // Uncached construction: create a brand-new formatter every iteration const startMiss = performance.now(); for (let i = 0; i < iterations; i++) { - getNumberFormat(locale, { maximumFractionDigits: 2 }); + new Intl.NumberFormat(locale, options); } const timeMiss = performance.now() - startMiss; + // Cache hit: look up the same cached formatter every iteration + getNumberFormat(locale, options); const startHit = performance.now(); for (let i = 0; i < iterations; i++) { - getNumberFormat(locale, { maximumFractionDigits: 2 }); + getNumberFormat(locale, options); } const timeHit = performance.now() - startHit;