diff --git a/backend/src/lib/rpc-pool.ts b/backend/src/lib/rpc-pool.ts index 07d2ccc5..ebc08d67 100644 --- a/backend/src/lib/rpc-pool.ts +++ b/backend/src/lib/rpc-pool.ts @@ -70,13 +70,16 @@ export class RpcPool { for (const endpoint of this.availableEndpoints()) { const controller = new AbortController(); const started = Date.now(); + let timeoutHandle: ReturnType | undefined; try { const result = await Promise.race([ operation(endpoint.server, controller.signal), - new Promise((_, reject) => setTimeout(() => { - controller.abort(); - reject(new Error(`${label} timed out after ${this.timeoutMs}ms`)); - }, this.timeoutMs)), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + controller.abort(); + reject(new Error(`${label} timed out after ${this.timeoutMs}ms`)); + }, this.timeoutMs); + }), ]); this.recordSuccess(endpoint, Date.now() - started); return result; @@ -84,6 +87,10 @@ export class RpcPool { lastError = error; this.recordFailure(endpoint, error, Date.now() - started); logger.warn(`[RpcPool] ${label} failed on ${endpoint.url}; failing over`, { error }); + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } } } throw lastError instanceof Error ? lastError : new Error(`${label} failed on all RPC endpoints`); @@ -125,15 +132,22 @@ export class RpcPool { private async probeAll(): Promise { await Promise.all(this.endpoints.map(async (endpoint) => { + let timeoutHandle: ReturnType | undefined; try { const started = Date.now(); await Promise.race([ Promise.all([endpoint.server.getHealth(), endpoint.server.getLatestLedger()]), - new Promise((_, reject) => setTimeout(() => reject(new Error('health probe timeout')), this.timeoutMs)), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error('health probe timeout')), this.timeoutMs); + }), ]); this.recordSuccess(endpoint, Date.now() - started); } catch (error) { this.recordFailure(endpoint, error, this.timeoutMs); + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } } })); } diff --git a/backend/tests/notification.service.test.ts b/backend/tests/notification.service.test.ts new file mode 100644 index 00000000..b9a95315 --- /dev/null +++ b/backend/tests/notification.service.test.ts @@ -0,0 +1,404 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { prismaMock, webpushMock } = vi.hoisted(() => ({ + prismaMock: { + pushSubscription: { + findMany: vi.fn(), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + notificationChannel: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + linkCode: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + }, + webpushMock: { + setVapidDetails: vi.fn(), + sendNotification: vi.fn(), + }, +})); + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})); + +vi.mock('web-push', () => ({ + default: webpushMock, +})); + +import { + NotificationService, + type StreamEvent, +} from '../src/services/notification.service.js'; + +describe('NotificationService', () => { + const fetchMock = vi.fn(); + const originalEnv = { ...process.env }; + + const sampleEvent: StreamEvent = { + type: 'STREAM_CREATED', + address: 'GUSER123', + title: 'Stream Created', + body: 'A new stream of 100 XLM was created.', + url: '/streams/1', + }; + + const samplePushSub = { + endpoint: 'https://push.example.com/endpoint/1', + p256dh: 'test-p256dh-key', + auth: 'test-auth-key', + }; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = { + ...originalEnv, + VAPID_PUBLIC_KEY: 'test-vapid-public-key', + VAPID_PRIVATE_KEY: 'test-vapid-private-key', + VAPID_SUBJECT: 'mailto:test@flowfi.app', + TELEGRAM_BOT_TOKEN: 'test-telegram-token', + }; + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + webpushMock.sendNotification.mockResolvedValue({ statusCode: 201 }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('dispatch - failure isolation', () => { + it('attempts Telegram and Discord even when WebPush delivery fails', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + address: 'GUSER123', + telegramChatId: '12345678', + discordWebhook: 'https://discord.com/api/webhooks/123/abc', + }); + + // Push rejects + webpushMock.sendNotification.mockRejectedValue(new Error('Push service unavailable')); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(webpushMock.sendNotification).toHaveBeenCalledTimes(1); + + // Verify Telegram was still attempted + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.telegram.org/bottest-telegram-token/sendMessage', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('Stream Created'), + }), + ); + + // Verify Discord was still attempted + expect(fetchMock).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('Stream Created'), + }), + ); + }); + + it('attempts Push and Discord even when Telegram delivery fails', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + address: 'GUSER123', + telegramChatId: '12345678', + discordWebhook: 'https://discord.com/api/webhooks/123/abc', + }); + + // Telegram fetch rejects + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('telegram')) { + throw new Error('Telegram network error'); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(webpushMock.sendNotification).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.anything(), + ); + }); + + it('attempts Push and Telegram even when Discord delivery fails', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + address: 'GUSER123', + telegramChatId: '12345678', + discordWebhook: 'https://discord.com/api/webhooks/123/abc', + }); + + // Discord fetch rejects + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('discord')) { + throw new Error('Discord rate limit / error'); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(webpushMock.sendNotification).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.telegram.org/bottest-telegram-token/sendMessage', + expect.anything(), + ); + }); + + it('delivers to remaining push subscriptions when one subscription fails', async () => { + const sub1 = { ...samplePushSub, endpoint: 'https://push.example.com/sub1' }; + const sub2 = { ...samplePushSub, endpoint: 'https://push.example.com/sub2' }; + + prismaMock.pushSubscription.findMany.mockResolvedValue([sub1, sub2]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + + webpushMock.sendNotification + .mockRejectedValueOnce(new Error('Sub 1 network failure')) + .mockResolvedValueOnce({ statusCode: 201 }); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(webpushMock.sendNotification).toHaveBeenCalledTimes(2); + expect(webpushMock.sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: sub1.endpoint }), + expect.any(String), + ); + expect(webpushMock.sendNotification).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: sub2.endpoint }), + expect.any(String), + ); + }); + }); + + describe('dispatch - expired subscriptions (404 and 410) and transient failures', () => { + it('deletes expired push subscription when service returns HTTP 404', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 }); + + const error404 = new Error('Not Found'); + (error404 as unknown as { statusCode: number }).statusCode = 404; + webpushMock.sendNotification.mockRejectedValue(error404); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ + where: { endpoint: samplePushSub.endpoint }, + }); + }); + + it('deletes expired push subscription when service returns HTTP 410 (Gone)', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 }); + + const error410 = new Error('Gone'); + (error410 as unknown as { statusCode: number }).statusCode = 410; + webpushMock.sendNotification.mockRejectedValue(error410); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ + where: { endpoint: samplePushSub.endpoint }, + }); + }); + + it('retains push subscription on transient failures (e.g. 500, 503, ETIMEDOUT)', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + + const error500 = new Error('Internal Server Error'); + (error500 as unknown as { statusCode: number }).statusCode = 500; + webpushMock.sendNotification.mockRejectedValue(error500); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(prismaMock.pushSubscription.deleteMany).not.toHaveBeenCalled(); + }); + + it('retains push subscription on network errors without statusCode', async () => { + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue(null); + + webpushMock.sendNotification.mockRejectedValue(new Error('Connection reset by peer')); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + expect(prismaMock.pushSubscription.deleteMany).not.toHaveBeenCalled(); + }); + }); + + describe('VAPID configuration handling', () => { + it('configures webpush when VAPID keys are present', () => { + new NotificationService(); + + expect(webpushMock.setVapidDetails).toHaveBeenCalledWith( + 'mailto:test@flowfi.app', + 'test-vapid-public-key', + 'test-vapid-private-key', + ); + }); + + it('prevents push delivery when VAPID configuration is absent', async () => { + delete process.env.VAPID_PUBLIC_KEY; + delete process.env.VAPID_PRIVATE_KEY; + + prismaMock.pushSubscription.findMany.mockResolvedValue([samplePushSub]); + prismaMock.notificationChannel.findUnique.mockResolvedValue({ + address: 'GUSER123', + telegramChatId: '12345678', + }); + + const service = new NotificationService(); + await service.dispatch(sampleEvent); + + // Push delivery skipped due to absent VAPID configuration + expect(webpushMock.sendNotification).not.toHaveBeenCalled(); + + // Telegram delivery should still proceed + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.telegram.org/bottest-telegram-token/sendMessage', + expect.anything(), + ); + }); + }); + + describe('subscription and link management helpers', () => { + it('subscribes web push endpoint via upsert', async () => { + prismaMock.pushSubscription.upsert.mockResolvedValue({ id: 'sub-1' }); + + const service = new NotificationService(); + await service.subscribeWebPush({ + address: 'GUSER123', + endpoint: 'https://push.example.com/endpoint', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' }, + userAgent: 'Mozilla/5.0', + }); + + expect(prismaMock.pushSubscription.upsert).toHaveBeenCalledWith({ + where: { endpoint: 'https://push.example.com/endpoint' }, + update: { + address: 'GUSER123', + p256dh: 'p256dh-key', + auth: 'auth-key', + userAgent: 'Mozilla/5.0', + }, + create: { + address: 'GUSER123', + endpoint: 'https://push.example.com/endpoint', + p256dh: 'p256dh-key', + auth: 'auth-key', + userAgent: 'Mozilla/5.0', + }, + }); + }); + + it('unsubscribes web push endpoint', async () => { + prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 }); + + const service = new NotificationService(); + await service.unsubscribeWebPush('GUSER123', 'https://push.example.com/endpoint'); + + expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ + where: { address: 'GUSER123', endpoint: 'https://push.example.com/endpoint' }, + }); + }); + + it('generates a valid link code', async () => { + prismaMock.linkCode.create.mockImplementation(async ({ data }) => ({ + id: 'lc-1', + ...data, + })); + + const service = new NotificationService(); + const result = await service.generateLinkCode('GUSER123'); + + expect(result).toHaveProperty('code'); + expect(result.address).toBe('GUSER123'); + expect(prismaMock.linkCode.create).toHaveBeenCalledTimes(1); + }); + + it('links telegram when code is valid', async () => { + prismaMock.linkCode.findUnique.mockResolvedValue({ + code: 'VALID1', + address: 'GUSER123', + usedAt: null, + expiresAt: new Date(Date.now() + 60_000), + }); + prismaMock.linkCode.update.mockResolvedValue({}); + prismaMock.notificationChannel.upsert.mockResolvedValue({}); + + const service = new NotificationService(); + await service.linkTelegram('VALID1', 'chat_999'); + + expect(prismaMock.linkCode.update).toHaveBeenCalledWith({ + where: { code: 'VALID1' }, + data: { usedAt: expect.any(Date) }, + }); + expect(prismaMock.notificationChannel.upsert).toHaveBeenCalledWith({ + where: { address: 'GUSER123' }, + update: { telegramChatId: 'chat_999' }, + create: { address: 'GUSER123', telegramChatId: 'chat_999' }, + }); + }); + + it('rejects linking telegram when code is invalid or expired', async () => { + prismaMock.linkCode.findUnique.mockResolvedValue({ + code: 'EXPIRED', + address: 'GUSER123', + usedAt: null, + expiresAt: new Date(Date.now() - 60_000), + }); + + const service = new NotificationService(); + await expect(service.linkTelegram('EXPIRED', 'chat_999')).rejects.toThrow( + 'Link code is invalid or expired', + ); + }); + + it('sets discord webhook for valid url', async () => { + prismaMock.notificationChannel.upsert.mockResolvedValue({}); + + const service = new NotificationService(); + await service.setDiscordWebhook( + 'GUSER123', + 'https://discord.com/api/webhooks/12345/abcdef', + ); + + expect(prismaMock.notificationChannel.upsert).toHaveBeenCalledWith({ + where: { address: 'GUSER123' }, + update: { discordWebhook: 'https://discord.com/api/webhooks/12345/abcdef' }, + create: { address: 'GUSER123', discordWebhook: 'https://discord.com/api/webhooks/12345/abcdef' }, + }); + }); + + it('rejects invalid discord webhook url', async () => { + const service = new NotificationService(); + await expect( + service.setDiscordWebhook('GUSER123', 'https://malicious.example.com/webhook'), + ).rejects.toThrow('Invalid Discord webhook URL'); + }); + }); +}); diff --git a/backend/tests/rpc-pool.test.ts b/backend/tests/rpc-pool.test.ts new file mode 100644 index 00000000..009db6e6 --- /dev/null +++ b/backend/tests/rpc-pool.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { rpc } from '@stellar/stellar-sdk'; + +vi.mock('../src/logger.js', () => ({ + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { RpcPool } from '../src/lib/rpc-pool.js'; + +function createMockServer(overrides: Partial = {}) { + return { + getHealth: vi.fn().mockResolvedValue({ status: 'healthy' }), + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 1000 }), + ...overrides, + } as unknown as rpc.Server; +} + +describe('RpcPool', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('execute timer management', () => { + it('clears timeout timer on successful operation and does not abort signal later', async () => { + const server1 = createMockServer(); + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 5000, + createServer: () => server1, + }); + + let capturedSignal!: AbortSignal; + const resultPromise = pool.execute('test-op', async (_server, signal) => { + capturedSignal = signal; + return 'success-value'; + }); + + const result = await resultPromise; + expect(result).toBe('success-value'); + + // Timer must be cleared immediately after settling + expect(vi.getTimerCount()).toBe(0); + + // Advance time past the original timeout; signal must not be aborted + await vi.advanceTimersByTimeAsync(10_000); + expect(capturedSignal.aborted).toBe(false); + }); + + it('clears timeout timer on early failure before timeout', async () => { + const server1 = createMockServer(); + const server2 = createMockServer(); + const servers = [server1, server2]; + let serverIndex = 0; + + const pool = new RpcPool(['https://rpc1.example.com', 'https://rpc2.example.com'], { + timeoutMs: 5000, + createServer: () => servers[serverIndex++] ?? server2, + }); + + let endpoint1Signal!: AbortSignal; + let endpoint2Signal!: AbortSignal; + let callCount = 0; + + const resultPromise = pool.execute('failover-op', async (_server, signal) => { + callCount++; + if (callCount === 1) { + endpoint1Signal = signal; + throw new Error('immediate rpc 1 network failure'); + } + endpoint2Signal = signal; + return 'recovered-value'; + }); + + const result = await resultPromise; + expect(result).toBe('recovered-value'); + expect(callCount).toBe(2); + + // Timers should be cleared + expect(vi.getTimerCount()).toBe(0); + + // Advancing past timeoutMs should not abort endpoint 1 or endpoint 2 + await vi.advanceTimersByTimeAsync(10_000); + expect(endpoint1Signal.aborted).toBe(false); + expect(endpoint2Signal.aborted).toBe(false); + }); + + it('clears timeout timer when all endpoints fail early', async () => { + const server1 = createMockServer(); + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 5000, + createServer: () => server1, + }); + + const resultPromise = pool.execute('all-fail', async () => { + throw new Error('rpc unavailable'); + }); + + await expect(resultPromise).rejects.toThrow('rpc unavailable'); + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(10_000); + }); + + it('preserves timeout and aborts signal for genuinely hung requests, failing over', async () => { + const server1 = createMockServer(); + const server2 = createMockServer(); + const servers = [server1, server2]; + let serverIndex = 0; + + const pool = new RpcPool(['https://rpc1.example.com', 'https://rpc2.example.com'], { + timeoutMs: 3000, + createServer: () => servers[serverIndex++] ?? server2, + }); + + let endpoint1Signal!: AbortSignal; + let callCount = 0; + + const resultPromise = pool.execute('hung-op', async (_server, signal) => { + callCount++; + if (callCount === 1) { + endpoint1Signal = signal; + // Hang indefinitely until abort signal fires + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('aborted by controller')); + }); + }); + } + return 'recovered-after-hang'; + }); + + // Advance timers by 3000ms to trigger the timeout on endpoint 1 + await vi.advanceTimersByTimeAsync(3000); + + const result = await resultPromise; + expect(result).toBe('recovered-after-hang'); + expect(callCount).toBe(2); + expect(endpoint1Signal.aborted).toBe(true); + + // Post-recovery, timer count should be zero + expect(vi.getTimerCount()).toBe(0); + }); + + it('rejects with timeout when all endpoints genuinely hang', async () => { + const server1 = createMockServer(); + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 2000, + createServer: () => server1, + }); + + let capturedSignal!: AbortSignal; + const resultPromise = pool.execute('all-hang', async (_server, signal) => { + capturedSignal = signal; + return new Promise(() => undefined); + }); + + const assertion = expect(resultPromise).rejects.toThrow('all-hang timed out after 2000ms'); + + await vi.advanceTimersByTimeAsync(2000); + + await assertion; + expect(capturedSignal.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + }); + + describe('probeAll timer management', () => { + it('clears timeout timer on successful health probe', async () => { + const server1 = createMockServer({ + getHealth: vi.fn().mockResolvedValue({ status: 'healthy' }), + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 500 }), + }); + + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 4000, + createServer: () => server1, + }); + + await pool.probe(); + expect(vi.getTimerCount()).toBe(0); + + const [health] = pool.getHealth(); + expect(health?.state).toBe('CLOSED'); + expect(health?.failures).toBe(0); + }); + + it('clears timeout timer on early probe failure', async () => { + const server1 = createMockServer({ + getHealth: vi.fn().mockRejectedValue(new Error('endpoint down')), + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 500 }), + }); + + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 4000, + createServer: () => server1, + }); + + await pool.probe(); + expect(vi.getTimerCount()).toBe(0); + + const [health] = pool.getHealth(); + expect(health?.failures).toBe(1); + }); + + it('handles probe timeout when probe genuinely hangs', async () => { + const server1 = createMockServer({ + getHealth: vi.fn().mockReturnValue(new Promise(() => undefined)), + getLatestLedger: vi.fn().mockReturnValue(new Promise(() => undefined)), + }); + + const pool = new RpcPool(['https://rpc1.example.com'], { + timeoutMs: 2000, + createServer: () => server1, + }); + + const probePromise = pool.probe(); + await vi.advanceTimersByTimeAsync(2000); + await probePromise; + + expect(vi.getTimerCount()).toBe(0); + const [health] = pool.getHealth(); + expect(health?.failures).toBe(1); + }); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 2599d89a..63982da0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "@tanstack/react-query": "^5.101.0", "@tanstack/react-virtual": "^3.14.9", "lucide-react": "^0.575.0", - "next": "16.3.8", + "next": "16.4.2", "next-themes": "^0.4.6", "react": "19.2.7", "react-dom": "19.2.7", diff --git a/frontend/src/__tests__/stream-analytics.test.tsx b/frontend/src/__tests__/stream-analytics.test.tsx index 3db6e0e8..8077086b 100644 --- a/frontend/src/__tests__/stream-analytics.test.tsx +++ b/frontend/src/__tests__/stream-analytics.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import type { Stream } from '@/types/analytics'; import { DEFAULT_FILTERS, applyFilters } from '@/utils/analytics-filters'; import { @@ -9,6 +9,17 @@ import { toCsv, } from '@/utils/export-accounting'; import { StreamAnalyticsView } from '@/components/analytics/StreamAnalyticsView'; +import { + normalizeStream, + normalizeStreams, + getDefaultStreamsEndpoint, +} from '@/hooks/useStreams'; +import { TOKEN_ADDRESSES } from '@/lib/soroban'; +import AnalyticsPage from '@/app/analytics/page'; + +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(), +})); const NOW = Date.parse('2025-01-15T12:00:00Z'); const NOW_SECONDS = Math.floor(NOW / 1000); @@ -274,3 +285,311 @@ describe('StreamAnalyticsView', () => { expect(onChange).toHaveBeenCalledWith({ dateRange: '7d' }); }); }); + +describe('normalizeStream and normalizeStreams (Issue #1709)', () => { + it('prefers on-chain streamId over database UUID id without silently substituting UUID', () => { + const restStream = { + id: 'a8f7c9e0-1234-5678-9abc-def012345678', + streamId: '42', + sender: 'GSENDER1111111111111111111111111111111111111111111111111111', + recipient: 'GRECIPIENT1111111111111111111111111111111111111111111111111', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '10000000', + withdrawnAmount: '2000000', + startTime: 1710000000, + endTime: 1710086400, + isActive: true, + isPaused: false, + }; + + const normalized = normalizeStream(restStream); + expect(normalized).not.toBeNull(); + expect(normalized?.id).toBe('42'); + expect(normalized?.id).not.toBe('a8f7c9e0-1234-5678-9abc-def012345678'); + }); + + it('supports numeric streamId and snake_case stream_id', () => { + const streamNumeric = normalizeStream({ + id: 'db-uuid-1', + streamId: 77, + tokenAddress: TOKEN_ADDRESSES.XLM, + }); + expect(streamNumeric?.id).toBe('77'); + + const streamSnake = normalizeStream({ + id: 'db-uuid-2', + stream_id: '99', + tokenAddress: TOKEN_ADDRESSES.EURC, + }); + expect(streamSnake?.id).toBe('99'); + }); + + it('maps REST startTime and endTime to timestamps without zero dates', () => { + const restStream = { + id: 'uuid-1', + streamId: '10', + startTime: 1700000000, + endTime: 1700086400, + tokenAddress: TOKEN_ADDRESSES.USDC, + }; + + const normalized = normalizeStream(restStream); + expect(normalized?.startTimestamp).toBe(1700000000); + expect(normalized?.endTimestamp).toBe(1700086400); + expect(normalized?.createdAt).toBe(new Date(1700000000 * 1000).toISOString()); + }); + + it('maps REST state flags isActive and isPaused correctly across all states', () => { + // 1. Paused stream + const paused = normalizeStream({ + id: '1', + streamId: '1', + isActive: true, + isPaused: true, + tokenAddress: TOKEN_ADDRESSES.USDC, + }); + expect(paused?.status).toBe('paused'); + + // 2. Active stream + const active = normalizeStream({ + id: '2', + streamId: '2', + isActive: true, + isPaused: false, + tokenAddress: TOKEN_ADDRESSES.USDC, + }); + expect(active?.status).toBe('active'); + + // 3. Completed stream (inactive, no CANCELLED event) + const completed = normalizeStream({ + id: '3', + streamId: '3', + isActive: false, + isPaused: false, + tokenAddress: TOKEN_ADDRESSES.USDC, + events: [{ eventType: 'COMPLETED' }], + }); + expect(completed?.status).toBe('completed'); + + // 4. Cancelled stream (inactive, with CANCELLED event) + const cancelled = normalizeStream({ + id: '4', + streamId: '4', + isActive: false, + isPaused: false, + tokenAddress: TOKEN_ADDRESSES.USDC, + events: [{ eventType: 'CANCELLED' }], + }); + expect(cancelled?.status).toBe('cancelled'); + }); + + it('resolves token symbol and scales with 7 decimals instead of silently substituting 18', () => { + const usdcStream = normalizeStream({ + id: '1', + streamId: '1', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '70000000', + }); + expect(usdcStream?.token.symbol).toBe('USDC'); + expect(usdcStream?.token.decimals).toBe(7); + expect(usdcStream?.token.decimals).not.toBe(18); + + const xlmStream = normalizeStream({ + id: '2', + streamId: '2', + tokenAddress: TOKEN_ADDRESSES.XLM, + }); + expect(xlmStream?.token.symbol).toBe('XLM'); + expect(xlmStream?.token.decimals).toBe(7); + + // Unknown contract address defaults to 7 (Stellar standard scale), never 18 + const unknownStream = normalizeStream({ + id: '3', + streamId: '3', + tokenAddress: 'CCUSTOMTOKENADDRESS1234567890', + }); + expect(unknownStream?.token.decimals).toBe(7); + expect(unknownStream?.token.decimals).not.toBe(18); + + // Explicit decimals in token record are preserved + const explicitStream = normalizeStream({ + id: '4', + streamId: '4', + token: { symbol: 'DAI', address: '0xdai', decimals: 18 }, + }); + expect(explicitStream?.token.decimals).toBe(18); + }); + + it('calculates remainingBalance from depositedAmount and withdrawnAmount when omitted', () => { + const stream = normalizeStream({ + id: '1', + streamId: '1', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '1000000000', + withdrawnAmount: '250000000', + }); + expect(stream?.remainingBalance).toBe('750000000'); + }); + + it('normalizes wrapped array { data: [...] } and plain array [...] responses', () => { + const record = { id: 'uuid-1', streamId: '1', tokenAddress: TOKEN_ADDRESSES.USDC }; + const fromWrapped = normalizeStreams({ data: [record] }); + expect(fromWrapped).toHaveLength(1); + expect(fromWrapped[0]?.id).toBe('1'); + + const fromArray = normalizeStreams([record]); + expect(fromArray).toHaveLength(1); + expect(fromArray[0]?.id).toBe('1'); + }); +}); + +describe('getDefaultStreamsEndpoint', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns backend streams endpoint respecting configured NEXT_PUBLIC_API_URL', () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001/v1'; + expect(getDefaultStreamsEndpoint()).toBe('http://localhost:3001/v1/streams'); + + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001'; + expect(getDefaultStreamsEndpoint()).toBe('http://localhost:3001/v1/streams'); + + delete process.env.NEXT_PUBLIC_API_URL; + expect(getDefaultStreamsEndpoint()).toBe('http://localhost:3001/v1/streams'); + }); +}); + +describe('Mounted AnalyticsPage against backend responses (Issue #1709)', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('mounts and renders active, paused, completed, and cancelled streams from backend response', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const backendData = { + data: [ + { + id: 'uuid-active-1', + streamId: '101', + sender: 'GSENDER1111111111111111111111111111111111111111111111111111', + recipient: 'GRECIPIENT1111111111111111111111111111111111111111111111111', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '1000000000', + withdrawnAmount: '250000000', + startTime: nowSeconds - 86400 * 5, + endTime: nowSeconds + 86400 * 10, + isActive: true, + isPaused: false, + createdAt: new Date((nowSeconds - 86400 * 5) * 1000).toISOString(), + }, + { + id: 'uuid-paused-2', + streamId: '102', + sender: 'GSENDER2222222222222222222222222222222222222222222222222222', + recipient: 'GRECIPIENT2222222222222222222222222222222222222222222222222', + tokenAddress: TOKEN_ADDRESSES.XLM, + depositedAmount: '500000000', + withdrawnAmount: '100000000', + startTime: nowSeconds - 86400 * 3, + endTime: nowSeconds + 86400 * 7, + isActive: true, + isPaused: true, + createdAt: new Date((nowSeconds - 86400 * 3) * 1000).toISOString(), + }, + { + id: 'uuid-cancelled-3', + streamId: '103', + sender: 'GSENDER3333333333333333333333333333333333333333333333333333', + recipient: 'GRECIPIENT3333333333333333333333333333333333333333333333333', + tokenAddress: TOKEN_ADDRESSES.EURC, + depositedAmount: '300000000', + withdrawnAmount: '50000000', + startTime: nowSeconds - 86400 * 8, + endTime: nowSeconds + 86400 * 2, + isActive: false, + isPaused: false, + events: [{ eventType: 'CANCELLED' }], + createdAt: new Date((nowSeconds - 86400 * 8) * 1000).toISOString(), + }, + { + id: 'uuid-completed-4', + streamId: '104', + sender: 'GSENDER4444444444444444444444444444444444444444444444444444', + recipient: 'GRECIPIENT4444444444444444444444444444444444444444444444444', + tokenAddress: TOKEN_ADDRESSES.USDC, + depositedAmount: '200000000', + withdrawnAmount: '200000000', + startTime: nowSeconds - 86400 * 20, + endTime: nowSeconds - 86400 * 2, + isActive: false, + isPaused: false, + events: [{ eventType: 'COMPLETED' }], + createdAt: new Date((nowSeconds - 86400 * 20) * 1000).toISOString(), + }, + ], + total: 4, + }; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => backendData, + }); + global.fetch = fetchMock; + + render(); + + // Verify endpoint called is documented backend stream list + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/streams$/), + expect.objectContaining({ headers: { Accept: 'application/json' } }), + ); + }); + + // Wait for the rows to appear in the table + await waitFor(() => { + expect(screen.getByTestId('stream-row-101')).toBeInTheDocument(); + expect(screen.getByTestId('stream-row-102')).toBeInTheDocument(); + expect(screen.getByTestId('stream-row-103')).toBeInTheDocument(); + expect(screen.getByTestId('stream-row-104')).toBeInTheDocument(); + }); + + // Verify on-chain stream IDs are shown rather than UUIDs + expect(screen.getByTestId('stream-row-101')).toHaveTextContent('101'); + expect(screen.getByTestId('stream-row-101')).not.toHaveTextContent('uuid-active-1'); + + // Verify statuses rendered correctly in the UI + expect(screen.getByTestId('stream-row-101')).toHaveTextContent(/active/i); + expect(screen.getByTestId('stream-row-102')).toHaveTextContent(/paused/i); + expect(screen.getByTestId('stream-row-103')).toHaveTextContent(/cancelled/i); + expect(screen.getByTestId('stream-row-104')).toHaveTextContent(/completed/i); + + // Verify KPI ribbon rendered + expect(screen.getByTestId('kpi-ribbon')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-active')).toHaveTextContent('1'); // only stream 101 is active with runway + }); + + it('renders error state on API failure', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + global.fetch = fetchMock; + + render(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load streams (500)'); + }); + }); +}); diff --git a/frontend/src/hooks/useStreams.ts b/frontend/src/hooks/useStreams.ts index 3295fd09..5eb03bee 100644 --- a/frontend/src/hooks/useStreams.ts +++ b/frontend/src/hooks/useStreams.ts @@ -2,6 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { Stream, StreamStatus } from '@/types/analytics'; +import { getApiBaseUrl } from '@/lib/api/_shared'; +import { TOKEN_ADDRESSES } from '@/lib/soroban'; +import { getDefaultTokenDecimals } from '@/utils/amount'; const VALID_STATUSES: StreamStatus[] = ['active', 'paused', 'completed', 'cancelled']; @@ -15,41 +18,172 @@ function num(value: unknown, fallback = 0): number { return Number.isFinite(parsed) ? parsed : fallback; } +function parseBigIntSafe(val: unknown): bigint { + if (val === null || val === undefined) return 0n; + try { + const s = String(val).trim(); + if (!s) return 0n; + const [whole] = s.split('.'); + return BigInt(whole || '0'); + } catch { + return 0n; + } +} + +export function getDefaultStreamsEndpoint(): string { + const base = getApiBaseUrl(); + return `${base}${base.endsWith('/v1') ? '' : '/v1'}/streams`; +} + export function normalizeStream(raw: unknown): Stream | null { if (!raw || typeof raw !== 'object') return null; const record = raw as Record; - const id = str(record.id ?? record.streamId ?? record.stream_id); + // Prefer on-chain streamId over database UUID id (issue #1709) + const onChainId = record.streamId ?? record.stream_id; + const id = + onChainId !== undefined && onChainId !== null && String(onChainId).trim() !== '' + ? str(onChainId) + : str(record.id); if (!id) return null; - const tokenRecord = (record.token ?? {}) as Record; - const status = str(record.status, 'active') as StreamStatus; + const tokenRecord = (record.token && typeof record.token === 'object' + ? record.token + : {}) as Record; + + const tokenAddress = str( + tokenRecord.address ?? record.tokenAddress ?? record.token_address, + ); + + // Resolve token symbol: from token object, or matching tokenAddress against known tokens + let symbol = str(tokenRecord.symbol ?? record.tokenSymbol ?? record.token_symbol); + if (!symbol && tokenAddress) { + const knownEntry = Object.entries(TOKEN_ADDRESSES).find( + ([, addr]) => addr.toLowerCase() === tokenAddress.toLowerCase(), + ); + if (knownEntry) { + symbol = knownEntry[0]; + } else { + symbol = `${tokenAddress.slice(0, 6)}...${tokenAddress.slice(-4)}`; + } + } + if (!symbol) symbol = 'UNKNOWN'; + + // Resolve decimals: explicit decimals take precedence, otherwise default based on symbol (never silent 18) + const rawDecimals = tokenRecord.decimals ?? record.tokenDecimals ?? record.token_decimals; + const decimals = + rawDecimals !== undefined && rawDecimals !== null + ? num(rawDecimals, getDefaultTokenDecimals(symbol)) + : getDefaultTokenDecimals(symbol); + + // Timestamps: map REST startTime / endTime without zero dates + const startTimestamp = num( + record.startTimestamp ?? + record.start_timestamp ?? + record.startTime ?? + record.start_time, + ); + + const endTimestamp = num( + record.endTimestamp ?? + record.end_timestamp ?? + record.endTime ?? + record.end_time, + ); + + const cliffTimestamp = num( + record.cliffTimestamp ?? + record.cliff_timestamp ?? + record.cliffTime ?? + record.cliff_time, + ); + + const lastActionLedger = num( + record.lastActionLedger ?? + record.last_action_ledger ?? + record.lastLedger ?? + record.last_ledger, + ); + + const createdAt = str( + record.createdAt ?? + record.created_at ?? + (startTimestamp > 0 + ? new Date(startTimestamp * 1000).toISOString() + : new Date().toISOString()), + ); + + // Status mapping: support explicit string as well as REST boolean flags isActive and isPaused + let status: StreamStatus; + const rawStatus = typeof record.status === 'string' ? record.status.toLowerCase().trim() : ''; + if (VALID_STATUSES.includes(rawStatus as StreamStatus)) { + status = rawStatus as StreamStatus; + } else { + const isPaused = Boolean(record.isPaused ?? record.is_paused); + const isActive = + record.isActive !== undefined + ? Boolean(record.isActive) + : record.is_active !== undefined + ? Boolean(record.is_active) + : true; + + if (isPaused) { + status = 'paused'; + } else if (isActive) { + status = 'active'; + } else { + const events = Array.isArray(record.events) ? record.events : []; + const wasCancelled = events.some( + (e: unknown) => + typeof e === 'object' && + e !== null && + ( + (e as Record).eventType === 'CANCELLED' || + (e as Record).type === 'CANCELLED' || + (e as Record).type === 'cancelled' + ), + ); + status = wasCancelled ? 'cancelled' : 'completed'; + } + } + + const depositedAmount = str(record.depositedAmount ?? record.deposited_amount ?? '0'); + const withdrawnAmount = str(record.withdrawnAmount ?? record.withdrawn_amount ?? '0'); + + let remainingBalance = str(record.remainingBalance ?? record.remaining_balance); + if (!remainingBalance) { + const dep = parseBigIntSafe(depositedAmount); + const withdr = parseBigIntSafe(withdrawnAmount); + remainingBalance = dep >= withdr ? (dep - withdr).toString() : '0'; + } + + const claimableBalance = str(record.claimableBalance ?? record.claimable_balance ?? '0'); return { id, - createdAt: str(record.createdAt ?? record.created_at ?? new Date().toISOString()), + createdAt, sender: str(record.sender ?? record.senderAddress ?? record.sender_address), recipient: str(record.recipient ?? record.recipientAddress ?? record.recipient_address), memo: record.memo ? str(record.memo) : undefined, token: { - symbol: str(tokenRecord.symbol ?? record.tokenSymbol ?? record.token_symbol, 'UNKNOWN'), - address: str(tokenRecord.address ?? record.tokenAddress ?? record.token_address), - decimals: num(tokenRecord.decimals ?? record.tokenDecimals ?? record.token_decimals, 18), + symbol, + address: tokenAddress, + decimals, logoURI: tokenRecord.logoURI ? str(tokenRecord.logoURI) : record.tokenLogo ? str(record.tokenLogo) : undefined, }, - depositedAmount: str(record.depositedAmount ?? record.deposited_amount ?? '0'), - withdrawnAmount: str(record.withdrawnAmount ?? record.withdrawn_amount ?? '0'), - remainingBalance: str(record.remainingBalance ?? record.remaining_balance ?? '0'), - claimableBalance: str(record.claimableBalance ?? record.claimable_balance ?? '0'), - startTimestamp: num(record.startTimestamp ?? record.start_timestamp), - endTimestamp: num(record.endTimestamp ?? record.end_timestamp), - cliffTimestamp: num(record.cliffTimestamp ?? record.cliff_timestamp), - status: VALID_STATUSES.includes(status) ? status : 'active', - lastActionLedger: num(record.lastActionLedger ?? record.last_action_ledger), + depositedAmount, + withdrawnAmount, + remainingBalance, + claimableBalance, + startTimestamp, + endTimestamp, + cliffTimestamp, + status, + lastActionLedger, usdRate: record.usdRate !== undefined ? num(record.usdRate, 0) : undefined, }; } @@ -57,10 +191,10 @@ export function normalizeStream(raw: unknown): Stream | null { export function normalizeStreams(raw: unknown): Stream[] { const list = Array.isArray(raw) ? raw - : Array.isArray((raw as Record)?.streams) - ? ((raw as Record).streams as unknown[]) - : Array.isArray((raw as Record)?.data) - ? ((raw as Record).data as unknown[]) + : Array.isArray((raw as Record)?.data) + ? ((raw as Record).data as unknown[]) + : Array.isArray((raw as Record)?.streams) + ? ((raw as Record).streams as unknown[]) : []; return list @@ -83,10 +217,11 @@ export interface UseStreamsResult { } export function useStreams(options: UseStreamsOptions = {}): UseStreamsResult { + const defaultEndpoint = getDefaultStreamsEndpoint(); const { refreshInterval = 30_000, address, - endpoint = '/api/streams', + endpoint = defaultEndpoint, enabled = true, } = options; @@ -112,7 +247,10 @@ export function useStreams(options: UseStreamsOptions = {}): UseStreamsResult { const load = async (showSpinner: boolean) => { if (showSpinner) setIsLoading(true); try { - const url = new URL(endpoint, window.location.origin); + const url = new URL( + endpoint, + typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000', + ); if (address) url.searchParams.set('address', address); const response = await fetch(url.toString(), { diff --git a/frontend/src/utils/export-accounting.ts b/frontend/src/utils/export-accounting.ts index a380457b..e870cf18 100644 --- a/frontend/src/utils/export-accounting.ts +++ b/frontend/src/utils/export-accounting.ts @@ -1,5 +1,6 @@ import type { ExportFormat, Stream } from '@/types/analytics'; import { toDecimalString } from '@/utils/stream-metrics'; +import { getDefaultTokenDecimals } from '@/utils/amount'; export interface ExportColumn { key: string; @@ -50,7 +51,9 @@ function toIsoDate(value: string): string { function decimalsOf(stream: Stream): number { const decimals = stream.token?.decimals; - return Number.isFinite(decimals) ? Number(decimals) : 18; + return Number.isFinite(decimals) + ? Number(decimals) + : getDefaultTokenDecimals(stream.token?.symbol ?? ''); } export function buildAccountingRow(stream: Stream): AccountingRow { diff --git a/frontend/src/utils/stream-metrics.ts b/frontend/src/utils/stream-metrics.ts index 9e51ac51..8b972cf4 100644 --- a/frontend/src/utils/stream-metrics.ts +++ b/frontend/src/utils/stream-metrics.ts @@ -5,6 +5,7 @@ import type { StreamMetrics, TokenOutflow, } from '@/types/analytics'; +import { getDefaultTokenDecimals } from './amount'; export const SECONDS_PER_HOUR = 3_600; export const SECONDS_PER_DAY = 86_400; @@ -60,7 +61,7 @@ export function toDecimalString( } export function computeStreamMetrics(stream: Stream, nowMs: number = Date.now()): StreamMetrics { - const decimals = stream.token?.decimals ?? 18; + const decimals = stream.token?.decimals ?? getDefaultTokenDecimals(stream.token?.symbol ?? ''); const deposited = formatUnits(stream.depositedAmount, decimals); const withdrawn = formatUnits(stream.withdrawnAmount, decimals); const remaining = formatUnits(stream.remainingBalance, decimals); @@ -247,7 +248,7 @@ export function buildTokenVolumeSeries( const bucket = bucketKey !== undefined ? buckets.get(bucketKey) : undefined; if (bucket) { const symbol = stream.token?.symbol ?? 'UNKNOWN'; - const decimals = stream.token?.decimals ?? 18; + const decimals = stream.token?.decimals ?? getDefaultTokenDecimals(symbol); const amount = formatUnits(stream.depositedAmount, decimals); const rate = stream.usdRate ?? 0; diff --git a/package-lock.json b/package-lock.json index e15e86b8..d2282e85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "concurrently": "^9.1.2", "husky": "^9.1.7", "lint-staged": "^17.0.7", - "next": "16.3.8", + "next": "16.4.2", "vitest": "^3.2.7" }, "optionalDependencies": { @@ -158,7 +158,7 @@ "@tanstack/react-query": "^5.101.0", "@tanstack/react-virtual": "^3.14.9", "lucide-react": "^0.575.0", - "next": "16.3.8", + "next": "16.4.2", "next-themes": "^0.4.6", "react": "19.2.7", "react-dom": "19.2.7", @@ -587,7 +587,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -597,7 +597,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -631,7 +631,7 @@ "version": "7.29.9", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -691,7 +691,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1756,9 +1756,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1775,9 +1772,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1794,9 +1788,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1813,9 +1804,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1832,9 +1820,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1851,9 +1836,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1870,9 +1852,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1889,9 +1868,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1908,9 +1884,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1933,9 +1906,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1958,9 +1928,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1983,9 +1950,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2008,9 +1972,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2033,9 +1994,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2058,9 +2016,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2083,9 +2038,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2292,9 +2244,9 @@ } }, "node_modules/@next/env": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", - "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.2.tgz", + "integrity": "sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2308,9 +2260,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", - "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.2.tgz", + "integrity": "sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==", "cpu": [ "arm64" ], @@ -2324,9 +2276,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", - "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.2.tgz", + "integrity": "sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==", "cpu": [ "x64" ], @@ -2340,15 +2292,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", - "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.2.tgz", + "integrity": "sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2359,15 +2308,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", - "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.2.tgz", + "integrity": "sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2378,15 +2324,12 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", - "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.2.tgz", + "integrity": "sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2397,15 +2340,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", - "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.2.tgz", + "integrity": "sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2416,9 +2356,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", - "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.2.tgz", + "integrity": "sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==", "cpu": [ "arm64" ], @@ -2432,9 +2372,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", - "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.2.tgz", + "integrity": "sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==", "cpu": [ "x64" ], @@ -5946,7 +5886,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -10858,19 +10798,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -12012,7 +11939,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.25.4", @@ -12310,12 +12237,12 @@ } }, "node_modules/next": { - "version": "16.3.8", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", - "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.2.tgz", + "integrity": "sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==", "license": "MIT", "dependencies": { - "@next/env": "16.3.8", + "@next/env": "16.4.2", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -12329,15 +12256,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.8", - "@next/swc-darwin-x64": "16.3.8", - "@next/swc-linux-arm64-gnu": "16.3.8", - "@next/swc-linux-arm64-musl": "16.3.8", - "@next/swc-linux-x64-gnu": "16.3.8", - "@next/swc-linux-x64-musl": "16.3.8", - "@next/swc-win32-arm64-msvc": "16.3.8", - "@next/swc-win32-x64-msvc": "16.3.8", - "sharp": "^0.35.4" + "@next/swc-darwin-arm64": "16.4.2", + "@next/swc-darwin-x64": "16.4.2", + "@next/swc-linux-arm64-gnu": "16.4.2", + "@next/swc-linux-arm64-musl": "16.4.2", + "@next/swc-linux-x64-gnu": "16.4.2", + "@next/swc-linux-x64-musl": "16.4.2", + "@next/swc-win32-arm64-msvc": "16.4.2", + "@next/swc-win32-x64-msvc": "16.4.2", + "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -13097,47 +13024,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" - } - }, - "node_modules/playwright": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", - "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.64.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright-core": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", - "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/pkg-types": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", diff --git a/package.json b/package.json index fe369163..ca769359 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "concurrently": "^9.1.2", "husky": "^9.1.7", "lint-staged": "^17.0.7", - "next": "16.3.8", + "next": "16.4.2", "vitest": "^3.2.7" }, "lint-staged": {