test(web): raise coverage for share and upload flows
This commit is contained in:
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 () => {
|
||||
|
||||
Reference in new issue
Block a user