test(web): raise coverage for share and upload flows

This commit is contained in:
xixu-me committed 2026-04-11 10:06:43 +08:00
1 parent 737d3b6c28
commit 9eaa5b512c
3 files changed
+376 -1

No files matched your search

@@ -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(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-3b', '', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
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(
<MemoryRouter>
@@ -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(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-8b', 'Extend transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
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(
<MemoryRouter>
@@ -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(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-5b', 'QR reset transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
await act(async () => {
await Promise.resolve()
})
expect(screen.getByAltText('Transfer QR code')).toHaveAttribute(
'src',
'data:image/png;base64,first',
)
rerender(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-5b', 'QR reset transfer', '2026-03-19T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
rerender(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-5b', 'QR reset transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
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(
<MemoryRouter>
<ShareCard
transfer={createTransfer('expired-2', 'Expired transfer', '2026-03-19T10:00:00.000Z', {
lastError: 'Upload stalled before expiry.',
})}
/>
</MemoryRouter>,
)
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(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-old', 'Old transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
rerender(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-new', 'New transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
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',
)
})
})
@@ -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'))
@@ -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 () => {