diff --git a/apps/web/components/settings/NotificationPreferences.test.tsx b/apps/web/components/settings/NotificationPreferences.test.tsx new file mode 100644 index 00000000..b9bc5b61 --- /dev/null +++ b/apps/web/components/settings/NotificationPreferences.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationPreferences } from './NotificationPreferences'; + +const { mockRequestNotificationPermission, mockPlayAudioAlert } = vi.hoisted(() => ({ + mockRequestNotificationPermission: vi.fn(), + mockPlayAudioAlert: vi.fn(), +})); + +vi.mock('../../lib/notifications/desktopPush', () => ({ + requestNotificationPermission: mockRequestNotificationPermission, + playAudioAlert: mockPlayAudioAlert, +})); + +describe('NotificationPreferences', () => { + const originalNotification = Reflect.get(window, 'Notification'); + + beforeEach(() => { + vi.clearAllMocks(); + Reflect.deleteProperty(window, 'Notification'); + }); + + afterEach(() => { + if (originalNotification === undefined) { + Reflect.deleteProperty(window, 'Notification'); + } else { + Reflect.set(window, 'Notification', originalNotification); + } + vi.restoreAllMocks(); + }); + + it('renders default status and displays the enable button when window.Notification is absent', () => { + Reflect.deleteProperty(window, 'Notification'); + + render(); + + expect(screen.getByText('default')).toBeDefined(); + expect(screen.getByRole('button', { name: /enable push notifications/i })).toBeDefined(); + }); + + it('does not display the enable button when Notification.permission is preset to granted', () => { + Reflect.set(window, 'Notification', { + permission: 'granted', + requestPermission: vi.fn(), + }); + + render(); + + expect(screen.getByText('granted')).toBeDefined(); + expect(screen.queryByRole('button', { name: /enable push notifications/i })).toBeNull(); + }); + + it('updates status and keeps the enable button when permission resolves to denied, but removes it when granted', async () => { + Reflect.set(window, 'Notification', { + permission: 'default', + requestPermission: vi.fn(), + }); + + mockRequestNotificationPermission.mockResolvedValueOnce('denied'); + + render(); + + const enableButton = screen.getByRole('button', { name: /enable push notifications/i }); + fireEvent.click(enableButton); + + await waitFor(() => { + expect(screen.getByText('denied')).toBeDefined(); + }); + expect(screen.getByRole('button', { name: /enable push notifications/i })).toBeDefined(); + + mockRequestNotificationPermission.mockResolvedValueOnce('granted'); + fireEvent.click(enableButton); + + await waitFor(() => { + expect(screen.getByText('granted')).toBeDefined(); + expect(screen.queryByRole('button', { name: /enable push notifications/i })).toBeNull(); + }); + }); + + it('unmounts tone controls on sound toggle uncheck and remounts with retained frequency on re-check', () => { + render(); + + const slider = screen.getByRole('slider'); + fireEvent.change(slider, { target: { value: '650' } }); + expect(screen.getByText('650 Hz')).toBeDefined(); + + const soundCheckbox = screen.getByRole('checkbox', { + name: /enable sound for incoming payments/i, + }); + expect(soundCheckbox).toBeChecked(); + + // Uncheck sound toggle + fireEvent.click(soundCheckbox); + + expect(screen.queryByRole('slider')).toBeNull(); + expect(screen.queryByText('650 Hz')).toBeNull(); + expect(screen.queryByRole('button', { name: /test audio tone/i })).toBeNull(); + + // Re-check sound toggle + fireEvent.click(soundCheckbox); + + const remountedSlider = screen.getByRole('slider') as HTMLInputElement; + expect(remountedSlider).toBeDefined(); + expect(remountedSlider.value).toBe('650'); + expect(screen.getByText('650 Hz')).toBeDefined(); + expect(screen.getByRole('button', { name: /test audio tone/i })).toBeDefined(); + }); + + it('calls playAudioAlert with 800 when slider is moved to 800 and test audio button is clicked, and asserts bounds', () => { + render(); + + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('min')).toBe('200'); + expect(slider.getAttribute('max')).toBe('1000'); + + fireEvent.change(slider, { target: { value: '800' } }); + expect(screen.getByText('800 Hz')).toBeDefined(); + + const testToneButton = screen.getByRole('button', { name: /test audio tone/i }); + fireEvent.click(testToneButton); + + expect(mockPlayAudioAlert).toHaveBeenCalledWith(800); + }); +}); diff --git a/apps/web/src/app/api/auth/verify/route.test.ts b/apps/web/src/app/api/auth/verify/route.test.ts index 5b9ee511..284b14a3 100644 --- a/apps/web/src/app/api/auth/verify/route.test.ts +++ b/apps/web/src/app/api/auth/verify/route.test.ts @@ -216,4 +216,28 @@ describe('/api/auth/verify POST', () => { const data = await res.json(); expect(data.success).toBe(true); }); + + test('rejects a structurally valid challenge signed by an unrelated keypair', async () => { + const unrelatedKeypair = Keypair.random(); + const nonce = 'h'.repeat(64); + const tx = buildChallenge(nonce); + tx.sign(unrelatedKeypair); + + const res = await POST(makeRequest({ xdr: tx.toXDR() })); + + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBe('Invalid signature'); + expect(mockCreateSession).not.toHaveBeenCalled(); + expect(res.headers.get('set-cookie')).toBeNull(); + expect(mockConsumeNonce).not.toHaveBeenCalled(); + }); + + test('returns 400 when xdr is present but not a parsable transaction', async () => { + const res = await POST(makeRequest({ xdr: 'not-a-valid-transaction-xdr' })); + + expect(res.status).toBe(400); + expect(res.status).not.toBe(500); + }); }); + diff --git a/apps/web/src/app/api/merchant/stores/consolidated/route.test.ts b/apps/web/src/app/api/merchant/stores/consolidated/route.test.ts index 0db0cdf7..921fdcf1 100644 --- a/apps/web/src/app/api/merchant/stores/consolidated/route.test.ts +++ b/apps/web/src/app/api/merchant/stores/consolidated/route.test.ts @@ -1,6 +1,29 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GET } from './route'; import { sumDecimalStrings } from './sum-decimal-strings'; +const { mockWithClient, mockWithMerchantClient, mockQuery, mockGetMerchantByAddress } = vi.hoisted( + () => { + const query = vi.fn(); + return { + mockWithClient: vi.fn(async (work: (client: unknown) => Promise) => work({})), + mockWithMerchantClient: vi.fn( + async (_merchantId: number, work: (client: unknown) => Promise) => + work({ query }), + ), + mockQuery: query, + mockGetMerchantByAddress: vi.fn().mockResolvedValue({ id: 1, address: `G${'A'.repeat(55)}` }), + }; + }, +); + +vi.mock('@/lib/db', () => ({ + ensureSchema: vi.fn(), + withClient: mockWithClient, + withMerchantClient: mockWithMerchantClient, +})); +vi.mock('@/lib/merchants', () => ({ getMerchantByAddress: mockGetMerchantByAddress })); + describe('sumDecimalStrings', () => { it('adds decimal values without floating-point precision loss', () => { expect(sumDecimalStrings(['0.1', '0.2', '10.0001'])).toBe('10.3001'); @@ -8,3 +31,224 @@ describe('sumDecimalStrings', () => { expect(sumDecimalStrings(['-0.5', '1.25'])).toBe('0.75'); }); }); + +describe('GET /api/merchant/stores/consolidated', () => { + const ORG = `G${'A'.repeat(55)}`; + const ORIGINAL_DATABASE_URL = process.env.DATABASE_URL; + + afterEach(() => { + if (ORIGINAL_DATABASE_URL === undefined) { + delete process.env.DATABASE_URL; + } else { + process.env.DATABASE_URL = ORIGINAL_DATABASE_URL; + } + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + process.env.DATABASE_URL = 'postgres://test'; + mockGetMerchantByAddress.mockResolvedValue({ id: 1, address: ORG }); + mockQuery.mockReset(); + }); + + it('returns 503 with no withClient call when DATABASE_URL is unset', async () => { + delete process.env.DATABASE_URL; + const response = await GET( + new Request('https://accensa.test/api/merchant/stores/consolidated', { + headers: { 'x-accensa-org-merchant': ORG }, + }), + ); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: 'Database is unavailable' }); + expect(mockWithClient).not.toHaveBeenCalled(); + expect(mockWithMerchantClient).not.toHaveBeenCalled(); + }); + + it('returns 401 when no identity header is provided', async () => { + const response = await GET( + new Request('https://accensa.test/api/merchant/stores/consolidated'), + ); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockWithClient).not.toHaveBeenCalled(); + }); + + it('returns 401 when a header resolves to no organization', async () => { + mockGetMerchantByAddress.mockResolvedValueOnce(null); + const response = await GET( + new Request('https://accensa.test/api/merchant/stores/consolidated', { + headers: { 'x-accensa-org-merchant': ORG }, + }), + ); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'Unauthorized' }); + expect(mockWithClient).toHaveBeenCalled(); + expect(mockWithMerchantClient).not.toHaveBeenCalled(); + }); + + it('takes precedence with x-accensa-org-merchant over x-accensa-merchant when both are present', async () => { + const ORG_PRIMARY = `G${'P'.repeat(55)}`; + const ORG_FALLBACK = `G${'F'.repeat(55)}`; + + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const response = await GET( + new Request('https://accensa.test/api/merchant/stores/consolidated', { + headers: { + 'x-accensa-org-merchant': ORG_PRIMARY, + 'x-accensa-merchant': ORG_FALLBACK, + }, + }), + ); + + expect(response.status).toBe(200); + expect(mockGetMerchantByAddress).toHaveBeenCalledWith(expect.anything(), ORG_PRIMARY); + expect(mockGetMerchantByAddress).not.toHaveBeenCalledWith(expect.anything(), ORG_FALLBACK); + }); + + it('queries payments under each store own merchant id and merges totals using decimal-string addition', async () => { + const orgId = 1; + const store1MerchantId = 101; + const store2MerchantId = 102; + + mockGetMerchantByAddress.mockResolvedValueOnce({ id: orgId, address: ORG }); + + mockQuery.mockImplementation(async (sql: string, params?: unknown[]) => { + if (sql.includes('FROM merchant_stores')) { + return { + rows: [ + { id: 10, name: 'Store Alpha', merchant_id: store1MerchantId }, + { id: 20, name: 'Store Beta', merchant_id: store2MerchantId }, + ], + }; + } + if (sql.includes('FROM payments')) { + const merchantId = params?.[0]; + if (merchantId === store1MerchantId) { + return { + rows: [{ asset: 'USDC', payment_count: '2', volume: '0.1' }], + }; + } + if (merchantId === store2MerchantId) { + return { + rows: [{ asset: 'USDC', payment_count: '3', volume: '0.2' }], + }; + } + } + return { rows: [] }; + }); + + const response = await GET( + new Request('https://accensa.test/api/merchant/stores/consolidated', { + headers: { 'x-accensa-org-merchant': ORG }, + }), + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + stores: Array<{ storeId: number; name: string; paymentCount: number; assets: Array<{ asset: string; volume: string }> }>; + totals: { paymentCount: number; assets: Array<{ asset: string; volume: string }> }; + }; + + // Assert each store payments are queried under THAT store's own merchant id (not organization's id 1) + expect(mockWithMerchantClient).toHaveBeenCalledWith(store1MerchantId, expect.any(Function)); + expect(mockWithMerchantClient).toHaveBeenCalledWith(store2MerchantId, expect.any(Function)); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('FROM payments WHERE merchant_id = $1'), + [store1MerchantId], + ); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('FROM payments WHERE merchant_id = $1'), + [store2MerchantId], + ); + + // Assert totals.paymentCount is the sum (2 + 3 = 5) + expect(body.totals.paymentCount).toBe(5); + + // Assert decimal-string sum merges 0.1 and 0.2 into '0.3' without float precision loss (not 0.30000000000000004) + expect(body.totals.assets).toEqual([{ asset: 'USDC', volume: '0.3' }]); + + expect(body.stores).toHaveLength(2); + expect(body.stores[0]).toEqual({ + storeId: 10, + name: 'Store Alpha', + paymentCount: 2, + assets: [{ asset: 'USDC', volume: '0.1' }], + }); + expect(body.stores[1]).toEqual({ + storeId: 20, + name: 'Store Beta', + paymentCount: 3, + assets: [{ asset: 'USDC', volume: '0.2' }], + }); + }); + + it('handles a store with zero payments with paymentCount 0 and no asset rows', async () => { + const store1MerchantId = 101; + const store2MerchantId = 102; + + mockQuery.mockImplementation(async (sql: string, params?: unknown[]) => { + if (sql.includes('FROM merchant_stores')) { + return { + rows: [ + { id: 10, name: 'Active Store', merchant_id: store1MerchantId }, + { id: 20, name: 'Empty Store', merchant_id: store2MerchantId }, + ], + }; + } + if (sql.includes('FROM payments')) { + const merchantId = params?.[0]; + if (merchantId === store1MerchantId) { + return { + rows: [{ asset: 'native', payment_count: '4', volume: '100' }], + }; + } + if (merchantId === store2MerchantId) { + return { rows: [] }; + } + } + return { rows: [] }; + }); + + const response = await GET( + new Request('https://accensa.test/api/merchant/stores/consolidated', { + headers: { 'x-accensa-org-merchant': ORG }, + }), + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + stores: Array<{ storeId: number; name: string; paymentCount: number; assets: Array<{ asset: string; volume: string }> }>; + totals: { paymentCount: number; assets: Array<{ asset: string; volume: string }> }; + }; + + expect(body.stores[1]).toEqual({ + storeId: 20, + name: 'Empty Store', + paymentCount: 0, + assets: [], + }); + expect(body.totals.paymentCount).toBe(4); + expect(body.totals.assets).toEqual([{ asset: 'native', volume: '100' }]); + }); + + it('yields 500 with no leaked error message when a query rejects', async () => { + mockQuery.mockRejectedValueOnce(new Error('Internal database connection failed with secret_token_xyz')); + + const response = await GET( + new Request('https://accensa.test/api/merchant/stores/consolidated', { + headers: { 'x-accensa-org-merchant': ORG }, + }), + ); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body).toEqual({ error: 'Unable to load consolidated reporting' }); + expect(JSON.stringify(body)).not.toContain('secret_token_xyz'); + expect(JSON.stringify(body)).not.toContain('Internal database connection'); + }); +}); diff --git a/apps/web/src/components/disputes/EvidenceUpload.test.tsx b/apps/web/src/components/disputes/EvidenceUpload.test.tsx new file mode 100644 index 00000000..badad248 --- /dev/null +++ b/apps/web/src/components/disputes/EvidenceUpload.test.tsx @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + EvidenceUpload, + MAX_EVIDENCE_BYTES, + validateEvidenceFile, +} from './EvidenceUpload'; + +describe('validateEvidenceFile', () => { + it('returns the type message for text/plain', () => { + const error = validateEvidenceFile({ type: 'text/plain', size: 1024 }); + expect(error).toBe('Upload a PDF, JPG, or PNG file.'); + }); + + it('returns the size message for files above 10 MiB', () => { + const error = validateEvidenceFile({ + type: 'application/pdf', + size: MAX_EVIDENCE_BYTES + 1, + }); + expect(error).toBe('Evidence files must be 10 MB or smaller.'); + }); + + it('returns null at exactly 10 MiB', () => { + const error = validateEvidenceFile({ + type: 'application/pdf', + size: MAX_EVIDENCE_BYTES, + }); + expect(error).toBeNull(); + }); + + it('returns the TYPE error for a file that is both wrong type and oversized', () => { + const error = validateEvidenceFile({ + type: 'text/plain', + size: MAX_EVIDENCE_BYTES + 5000, + }); + expect(error).toBe('Upload a PDF, JPG, or PNG file.'); + }); +}); + +describe('EvidenceUpload component', () => { + const originalFetch = global.fetch; + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + function getDropZone() { + return screen + .getByText('Upload proof of shipment, tracking, or signed invoices.') + .closest('div')!; + } + + it('shows the role="alert" text and performs NO fetch on invalid drop', () => { + render(); + + const invalidFile = new File(['text content'], 'notes.txt', { type: 'text/plain' }); + fireEvent.drop(getDropZone(), { + dataTransfer: { files: [invalidFile] }, + }); + + const alert = screen.getByRole('alert'); + expect(alert).toBeDefined(); + expect(alert.textContent).toBe('Upload a PDF, JPG, or PNG file.'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('produces exactly one POST whose FormData contains file and disputeId sent to encoded path', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ cid: 'bafy-test-cid-1' }), { status: 200 }), + ); + + const disputeIdWithSpecialChars = 'disp 100/audit branch'; + render(); + + const validFile = new File(['pdf binary data'], 'receipt.pdf', { + type: 'application/pdf', + }); + fireEvent.drop(getDropZone(), { + dataTransfer: { files: [validFile] }, + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe(`/api/disputes/${encodeURIComponent(disputeIdWithSpecialChars)}/evidence`); + expect(url).toContain('disp%20100%2Faudit%20branch'); + expect(init?.method).toBe('POST'); + + const formData = init?.body as FormData; + expect(formData).toBeInstanceOf(FormData); + expect(formData.get('disputeId')).toBe(disputeIdWithSpecialChars); + const uploadedFile = formData.get('file') as File; + expect(uploadedFile).toBeInstanceOf(File); + expect(uploadedFile.name).toBe('receipt.pdf'); + }); + + it('shows "Pinning evidence..." while upload is in flight', async () => { + let resolveInFlight!: (value: Response) => void; + mockFetch.mockReturnValueOnce( + new Promise((resolve) => { + resolveInFlight = resolve; + }), + ); + + render(); + + const validFile = new File(['image bytes'], 'screenshot.png', { + type: 'image/png', + }); + fireEvent.drop(getDropZone(), { + dataTransfer: { files: [validFile] }, + }); + + await waitFor(() => { + expect(screen.getByRole('status').textContent).toBe('Pinning evidence...'); + }); + + resolveInFlight( + new Response(JSON.stringify({ cid: 'bafy-flight-cid' }), { status: 200 }), + ); + + await waitFor(() => { + expect(screen.getByRole('status').textContent).toBe('Evidence pinned successfully.'); + }); + }); + + it('shows "Evidence could not be pinned." on 500 response and leaves status region empty', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'Server crash' }), { status: 500 }), + ); + + render(); + + const validFile = new File(['jpeg bytes'], 'photo.jpg', { + type: 'image/jpeg', + }); + fireEvent.drop(getDropZone(), { + dataTransfer: { files: [validFile] }, + }); + + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('Evidence could not be pinned.'); + }); + + expect(screen.getByRole('status').textContent).toBe(''); + }); + + it('shows the thrown message on a network throw', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network offline or socket timeout')); + + render(); + + const validFile = new File(['pdf data'], 'invoice.pdf', { + type: 'application/pdf', + }); + fireEvent.drop(getDropZone(), { + dataTransfer: { files: [validFile] }, + }); + + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('Network offline or socket timeout'); + }); + + expect(screen.getByRole('status').textContent).toBe(''); + }); + + it('shows success message and calls onUploaded with returned cid, and works when onUploaded is omitted', async () => { + const onUploaded = vi.fn(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ cid: 'cid-qwerty-123' }), { status: 200 }), + ); + + const { rerender } = render( + , + ); + + const validFile1 = new File(['pdf data'], 'doc1.pdf', { + type: 'application/pdf', + }); + fireEvent.drop(getDropZone(), { + dataTransfer: { files: [validFile1] }, + }); + + await waitFor(() => { + expect(screen.getByRole('status').textContent).toBe('Evidence pinned successfully.'); + }); + expect(onUploaded).toHaveBeenCalledWith('cid-qwerty-123'); + + // Test with onUploaded omitted + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ cid: 'cid-omitted-456' }), { status: 200 }), + ); + rerender(); + + const validFile2 = new File(['pdf data'], 'doc2.pdf', { + type: 'application/pdf', + }); + fireEvent.drop(getDropZone(), { + dataTransfer: { files: [validFile2] }, + }); + + await waitFor(() => { + expect(screen.getByRole('status').textContent).toBe('Evidence pinned successfully.'); + }); + }); + + it('calls preventDefault on dragover and drop, clears dragging styling state on drop, and triggers no fetch on empty FileList', () => { + render(); + + const dropZone = getDropZone(); + + // Initial state: border-slate-300, not dragging + expect(dropZone.className).toContain('border-slate-300'); + expect(dropZone.className).not.toContain('border-emerald-600 bg-emerald-50'); + + // dragover calls preventDefault and sets dragging styling state + const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }); + const dragOverPreventDefaultSpy = vi.spyOn(dragOverEvent, 'preventDefault'); + dropZone.dispatchEvent(dragOverEvent); + + expect(dragOverPreventDefaultSpy).toHaveBeenCalled(); + expect(dropZone.className).toContain('border-emerald-600 bg-emerald-50'); + + // drop calls preventDefault, clears dragging state, and dropping empty FileList triggers no fetch + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [] }, + }); + const dropPreventDefaultSpy = vi.spyOn(dropEvent, 'preventDefault'); + dropZone.dispatchEvent(dropEvent); + + expect(dropPreventDefaultSpy).toHaveBeenCalled(); + expect(dropZone.className).not.toContain('border-emerald-600 bg-emerald-50'); + expect(dropZone.className).toContain('border-slate-300'); + expect(mockFetch).not.toHaveBeenCalled(); + }); +});