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 () => {