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