Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/__tests__/sms/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('SMSQueue', () => {
let provider: TwilioProvider;

beforeEach(() => {
SMSQueue.clearAllDeliveryLogs();
provider = new TwilioProvider();
queue = new SMSQueue(provider, {
maxRetries: 3,
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/referral/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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 () => {
Expand Down
16 changes: 13 additions & 3 deletions src/app/store/notificationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { create } from 'zustand';
import { AppNotification } from '@/lib/notifications/types';
import { NotificationService } from '@/lib/notifications/service';

function load<T>(key: string, fallback: T): T {
function load<T>(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;
}
Expand All @@ -19,6 +19,16 @@ function save<T>(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 {
Expand All @@ -33,7 +43,7 @@ interface NotificationState {
}

export const useNotificationStore = create<NotificationState>((set, get) => ({
notifications: load<AppNotification[]>(STORAGE_KEY, []),
notifications: load<AppNotification[]>(STORAGE_KEY, [], dateReviver),
addNotification: (n) => {
const created = NotificationService.createNotification({
message: n.message,
Expand Down
4 changes: 2 additions & 2 deletions src/app/store/volunteerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import type { Volunteer, VolunteerSMSPreferences } from '@/types/volunteer';

const STORAGE_KEY = 'volunteers_v1';

function load<T>(key: string, fallback: T): T {
function load<T>(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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ describe('SocialInteractions – Additional edge cases', () => {

render(<SocialInteractions contentId={contentId} contentUrl={customUrl} />);
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());
});
});
43 changes: 25 additions & 18 deletions src/lib/sms/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ function createJobId(): string {
// In-memory delivery log store for aggregation
const deliveryLogs: Map<string, SMSDeliveryLog> = 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 = '';

Expand All @@ -54,16 +57,22 @@ export class SMSQueue {
});
}

/** Clear all shared delivery logs (useful for testing). */
static clearAllDeliveryLogs(): void {
deliveryLogs.clear();
}

enqueue(message: SMSMessage): Promise<SMSSendResult> {
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', {
Expand All @@ -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<SMSSendResult> {
private async runJob(job: InternalQueueJob): Promise<void> {
let result: SMSSendResult = {
success: false,
provider: this.provider.type,
Expand Down Expand Up @@ -158,7 +163,8 @@ export class SMSQueue {
provider: this.provider.type,
});

return result;
job.resolve(result);
return;
}

if (job.attempts < this.options.maxRetries) {
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions src/services/tipService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ export async function sendTip(payload: TipPayload): Promise<TipSendResult> {
});

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);
}

Expand Down
8 changes: 6 additions & 2 deletions src/utils/__tests__/intlCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading