diff --git a/apps/web/src/features/share/ShareCard.test.tsx b/apps/web/src/features/share/ShareCard.test.tsx index 206bca1..80d5e21 100644 --- a/apps/web/src/features/share/ShareCard.test.tsx +++ b/apps/web/src/features/share/ShareCard.test.tsx @@ -186,6 +186,35 @@ describe('ShareCard', () => { expect(navigator.clipboard.writeText).not.toHaveBeenCalled() }) + it('omits the share title when the transfer has no display name', async () => { + const shareMock = vi.fn(async () => {}) + Object.assign(navigator, { share: shareMock }) + + render( + + + , + ) + + await act(async () => { + await Promise.resolve() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Share link' })) + + await act(async () => { + await Promise.resolve() + }) + + expect(shareMock).toHaveBeenCalledWith({ + text: 'Encrypted files via Xdrop', + url: 'https://example.com/t/active-3b#k=test', + }) + }) + it('falls back to copying when the share sheet is unavailable', async () => { render( @@ -442,6 +471,31 @@ describe('ShareCard', () => { expect(screen.queryByText('Extend failed')).not.toBeInTheDocument() }) + it('falls back to a generic message when extending fails with a non-Error', async () => { + extendTransferMock.mockRejectedValueOnce('boom') + + render( + + + , + ) + + await act(async () => { + await Promise.resolve() + }) + + fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i })) + + await act(async () => { + await Promise.resolve() + }) + + expect(screen.getByText('Could not update this transfer right now.')).toBeInTheDocument() + }) + it('hides the extend action when local management is unavailable', async () => { render( @@ -519,4 +573,121 @@ describe('ShareCard', () => { expect(screen.queryByAltText('Transfer QR code')).not.toBeInTheDocument() expect(document.querySelector('.qr-placeholder')).not.toBeNull() }) + + it('clears a stale QR image when regenerating the same link fails later', async () => { + toDataUrlMock + .mockResolvedValueOnce('data:image/png;base64,first') + .mockRejectedValueOnce(new Error('QR failed')) + + const { rerender } = render( + + + , + ) + + await act(async () => { + await Promise.resolve() + }) + + expect(screen.getByAltText('Transfer QR code')).toHaveAttribute( + 'src', + 'data:image/png;base64,first', + ) + + rerender( + + + , + ) + + rerender( + + + , + ) + + await act(async () => { + await Promise.resolve() + }) + + expect(screen.queryByAltText('Transfer QR code')).not.toBeInTheDocument() + expect(document.querySelector('.qr-placeholder')).not.toBeNull() + }) + + it('shows the expired transfer error when one was saved locally', () => { + render( + + + , + ) + + expect(screen.getByText('Upload stalled before expiry.')).toBeInTheDocument() + }) + + it('ignores stale QR responses after the transfer changes', async () => { + let resolveFirstQr: ((value: string) => void) | undefined + let resolveSecondQr: ((value: string) => void) | undefined + toDataUrlMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstQr = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondQr = resolve + }), + ) + + const { rerender } = render( + + + , + ) + + rerender( + + + , + ) + + await act(async () => { + resolveFirstQr?.('data:image/png;base64,old') + await Promise.resolve() + }) + + expect(screen.queryByAltText('Transfer QR code')).not.toBeInTheDocument() + expect(document.querySelector('.qr-placeholder')).not.toBeNull() + + await act(async () => { + resolveSecondQr?.('data:image/png;base64,new') + await Promise.resolve() + }) + + expect(screen.getByAltText('Transfer QR code')).toHaveAttribute( + 'src', + 'data:image/png;base64,new', + ) + }) }) diff --git a/apps/web/src/features/upload/TransferContext.actions.test.tsx b/apps/web/src/features/upload/TransferContext.actions.test.tsx index 475761c..7793fb4 100644 --- a/apps/web/src/features/upload/TransferContext.actions.test.tsx +++ b/apps/web/src/features/upload/TransferContext.actions.test.tsx @@ -23,6 +23,7 @@ const { listTransfersMock, loadPersistedSourceFileMock, putSourcesMock, + persistSourceToOpfsMock, putTransferMock, registerFilesMock, resumeTransferMock, @@ -100,6 +101,7 @@ const { loadPersistedSourceFileMock: vi.fn( async (source: PersistedSourceRecord) => source.file ?? null, ), + persistSourceToOpfsMock: vi.fn(async () => null), putSourcesMock: vi.fn(async (records: PersistedSourceRecord[]) => { for (const record of records) { sourceMap.set(record.key, record) @@ -146,7 +148,7 @@ vi.mock('@/lib/files/persistentSources', () => ({ createIndexedDbSourceRecord: createIndexedDbSourceRecordMock, deletePersistedTransferSources: deletePersistedTransferSourcesMock, loadPersistedSourceFile: loadPersistedSourceFileMock, - persistSourceToOpfs: vi.fn(async () => null), + persistSourceToOpfs: persistSourceToOpfsMock, supportsOpfsSourcePersistence: supportsOpfsSourcePersistenceMock, })) @@ -325,6 +327,8 @@ describe('TransferProvider actions', () => { loadPersistedSourceFileMock.mockImplementation( async (source: PersistedSourceRecord) => source.file ?? null, ) + persistSourceToOpfsMock.mockReset() + persistSourceToOpfsMock.mockResolvedValue(null) putSourcesMock.mockClear() putTransferMock.mockClear() registerFilesMock.mockReset() @@ -404,6 +408,94 @@ describe('TransferProvider actions', () => { expect(deleteRemoteTransferMock).toHaveBeenCalledWith('t-created', 'manage-token') }) + it('rejects source selections that exceed the app transfer limit before creating a transfer', async () => { + renderProvider() + await waitFor(() => expect(latestContext).not.toBeNull()) + + const oversizedFile = new File(['payload'], 'huge.bin', { + lastModified: 2, + type: 'application/octet-stream', + }) + Object.defineProperty(oversizedFile, 'size', { + configurable: true, + value: 268_435_456, + }) + + await expect( + latestContext?.createTransfer( + [ + { + file: oversizedFile, + relativePath: 'huge.bin', + }, + ], + { + clearLocalSecretsOnReady: false, + displayName: 'Huge transfer', + expiresInSeconds: 3600, + stripMetadata: false, + }, + ) ?? Promise.resolve(), + ).rejects.toThrow('The limit is 256 MiB per transfer.') + + expect(createTransferApiMock).not.toHaveBeenCalled() + }) + + it('rejects prepared transfers that exceed the limit after smaller chunk sizing is applied', async () => { + renderProvider() + await waitFor(() => expect(latestContext).not.toBeNull()) + + createTransferApiMock.mockResolvedValueOnce({ + expiresAt: '2026-03-21T08:00:00.000Z', + manageToken: 'manage-token', + transferId: 't-prepared-limit', + uploadConfig: { chunkSize: 1_048_576 }, + }) + supportsOpfsSourcePersistenceMock.mockReturnValueOnce(true) + + const virtualLargeFile = new File(['payload'], 'virtual-large.bin', { + lastModified: 2, + type: 'application/octet-stream', + }) + Object.defineProperty(virtualLargeFile, 'size', { + configurable: true, + value: 268_434_000, + }) + persistSourceToOpfsMock.mockResolvedValueOnce({ + fileId: 'file-1', + key: 'file-1:source', + lastModified: 2, + name: 'virtual-large.bin', + opfsPath: 't-prepared-limit/file-1.bin', + relativePath: 'virtual-large.bin', + size: 268_434_000, + storage: 'opfs', + transferId: 't-prepared-limit', + type: 'application/octet-stream', + } satisfies PersistedSourceRecord) + + await expect( + latestContext?.createTransfer( + [ + { + file: virtualLargeFile, + relativePath: 'virtual-large.bin', + }, + ], + { + clearLocalSecretsOnReady: false, + displayName: 'Prepared limit transfer', + expiresInSeconds: 3600, + stripMetadata: false, + }, + ) ?? Promise.resolve(), + ).rejects.toThrow('This transfer would upload') + + expect(registerFilesMock).not.toHaveBeenCalled() + expect(deletePersistedTransferSourcesMock).toHaveBeenCalledWith('t-prepared-limit') + expect(deleteRemoteTransferMock).toHaveBeenCalledWith('t-prepared-limit', 'manage-token') + }) + it('extends saved transfers and blocks transfers without manage access', async () => { transfersStore.set('t1', makeTransferRecord('ready')) @@ -458,6 +550,33 @@ describe('TransferProvider actions', () => { expect(deleteTransferRecordMock).not.toHaveBeenCalled() }) + it('refreshes transfers in newest-first order', async () => { + transfersStore.set( + 't-old', + makeTransferRecord('ready', { + createdAt: '2026-03-20T08:00:00.000Z', + id: 't-old', + }), + ) + transfersStore.set( + 't-new', + makeTransferRecord('ready', { + createdAt: '2026-03-20T09:00:00.000Z', + id: 't-new', + }), + ) + + renderProvider() + + await waitFor(() => { + expect(screen.getByText('t-new:ready:none')).toBeInTheDocument() + expect(screen.getByText('t-old:ready:none')).toBeInTheDocument() + }) + + const transferRows = Array.from(document.querySelectorAll('p')).map((node) => node.textContent) + expect(transferRows.slice(0, 2)).toEqual(['t-new:ready:none', 't-old:ready:none']) + }) + it('marks interrupted uploads as failed when source files are missing', async () => { transfersStore.set('t1', makeTransferRecord('uploading')) @@ -472,6 +591,24 @@ describe('TransferProvider actions', () => { }) }) + it('skips resume attempts when a recovered transfer is no longer saved locally', async () => { + listTransfersMock.mockResolvedValueOnce([ + makeTransferRecord('paused', { + id: 'ghost', + lastError: + 'This page was closed or refreshed. Upload will continue automatically when you return here in the same browser on this device.', + }), + ]) + + renderProvider() + + await waitFor(() => { + expect(getTransferMock).toHaveBeenCalledWith('ghost') + }) + + expect(screen.queryByText(/ghost:/)).not.toBeInTheDocument() + }) + it('marks uploads as failed when hydrated source files disappear', async () => { transfersStore.set('t1', makeTransferRecord('uploading')) sourcesStore.set('t1:file-1', makeSourceRecord('t1')) diff --git a/apps/web/src/features/upload/UploadStudio.test.tsx b/apps/web/src/features/upload/UploadStudio.test.tsx index 1ab39ff..7bd89ff 100644 --- a/apps/web/src/features/upload/UploadStudio.test.tsx +++ b/apps/web/src/features/upload/UploadStudio.test.tsx @@ -232,6 +232,43 @@ describe('UploadStudio', () => { expect(await screen.findByText('restore failed')).toBeInTheDocument() }) + it('avoids restoring draft state after unmount', async () => { + const restoredFile = new File(['hello'], 'draft.txt', { lastModified: 12, type: 'text/plain' }) + let resolveDraft: + | ((value: { + settings: typeof draftState.settings + sources: typeof draftState.sources + }) => void) + | undefined + loadDraftMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveDraft = resolve + }), + ) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { unmount } = renderStudio() + unmount() + + await act(async () => { + resolveDraft?.({ + settings: { ...draftState.settings, displayName: 'Recovered draft' }, + sources: [ + { + draftKey: 'draft-1', + file: restoredFile, + relativePath: 'draft.txt', + }, + ], + }) + await Promise.resolve() + }) + + expect(errorSpy).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + it('falls back to default messages for non-Error draft restoration and launch failures', async () => { loadDraftMock.mockRejectedValueOnce('boom') @@ -250,6 +287,28 @@ describe('UploadStudio', () => { expect(await screen.findByText('Could not start the transfer.')).toBeInTheDocument() }) + it('avoids surfacing draft restoration errors after unmount', async () => { + let rejectDraft: ((reason?: unknown) => void) | undefined + loadDraftMock.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectDraft = reject + }), + ) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { unmount } = renderStudio() + unmount() + + await act(async () => { + rejectDraft?.(new Error('restore failed')) + await Promise.resolve() + }) + + expect(errorSpy).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + it('starts a transfer with trimmed settings, clears the draft, and navigates', async () => { const { container } = renderStudio() const file = new File(['hello'], 'draft.txt', { lastModified: 12, type: 'text/plain' }) @@ -372,6 +431,10 @@ describe('UploadStudio', () => { target: { files: [persistedFile] }, }) + await waitFor(() => { + expect(persistDraftMock).toHaveBeenCalledTimes(1) + }) + unmount() await act(async () => { @@ -407,6 +470,10 @@ describe('UploadStudio', () => { }, }) + await waitFor(() => { + expect(persistDraftMock).toHaveBeenCalledTimes(1) + }) + unmount() await act(async () => {