Files
xdrop/apps/web/src/features/upload/TransferContext.test.tsx
T

306 lines
8.7 KiB
TypeScript

import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManifestFileEntry } from '@/lib/api/types'
import type { LocalTransferRecord, PersistedSourceRecord } from '@/lib/indexeddb/db'
const {
deletePersistedTransferSourcesMock,
deleteSourcesForTransferMock,
encryptManifestMock,
finalizeTransferMock,
getSourcesForTransferMock,
getTransferMock,
listTransfersMock,
loadPersistedSourceFileMock,
putTransferMock,
resumeTransferMock,
sourcesStore,
transfersStore,
wrapRootKeyMock,
} = vi.hoisted(() => {
const transferMap = new Map<string, LocalTransferRecord>()
const sourceMap = new Map<string, PersistedSourceRecord>()
return {
transfersStore: transferMap,
sourcesStore: sourceMap,
listTransfersMock: vi.fn(async () => Array.from(transferMap.values())),
getTransferMock: vi.fn(async (id: string) => transferMap.get(id)),
putTransferMock: vi.fn(async (record: LocalTransferRecord) => {
transferMap.set(record.id, record)
}),
getSourcesForTransferMock: vi.fn(async (transferId: string) =>
Array.from(sourceMap.values()).filter((source) => source.transferId === transferId),
),
deleteSourcesForTransferMock: vi.fn(async (transferId: string) => {
for (const [key, source] of sourceMap.entries()) {
if (source.transferId === transferId) {
sourceMap.delete(key)
}
}
}),
deletePersistedTransferSourcesMock: vi.fn(async () => {}),
loadPersistedSourceFileMock: vi.fn(
async (source: PersistedSourceRecord) => source.file ?? null,
),
resumeTransferMock: vi.fn(async () => ({
uploadedChunks: {
'file-1': [0],
},
})),
uploadManifestMock: vi.fn(async () => {}),
finalizeTransferMock: vi.fn(async () => {}),
encryptManifestMock: vi.fn(async () => new Uint8Array([1, 2, 3])),
wrapRootKeyMock: vi.fn(async () => 'wrapped-root'),
}
})
vi.mock('@/lib/indexeddb/db', () => ({
deleteTransfer: vi.fn(async () => {}),
deleteSourcesForTransfer: deleteSourcesForTransferMock,
getSourcesForTransfer: getSourcesForTransferMock,
getTransfer: getTransferMock,
listTransfers: listTransfersMock,
putSources: vi.fn(async () => {}),
putTransfer: putTransferMock,
}))
vi.mock('@/lib/files/persistentSources', () => ({
createIndexedDbSourceRecord: vi.fn(),
deletePersistedTransferSources: deletePersistedTransferSourcesMock,
loadPersistedSourceFile: loadPersistedSourceFileMock,
persistSourceToOpfs: vi.fn(),
supportsOpfsSourcePersistence: vi.fn(() => false),
}))
vi.mock('@/lib/api/client', () => ({
apiClient: {
completeChunks: vi.fn(async () => {}),
createDownloadUrls: vi.fn(),
createTransfer: vi.fn(),
createUploadUrls: vi.fn(async () => []),
deleteTransfer: vi.fn(async () => {}),
finalizeTransfer: finalizeTransferMock,
getManageTransfer: vi.fn(),
getPublicTransfer: vi.fn(),
registerFiles: vi.fn(async () => {}),
resumeTransfer: resumeTransferMock,
updateTransfer: vi.fn(async () => {}),
uploadManifest: vi.fn(async () => {}),
},
}))
vi.mock('@/lib/workers/cryptoClient', () => ({
cryptoWorker: {
decryptChunk: vi.fn(),
decryptManifest: vi.fn(),
encryptChunk: vi.fn(),
encryptManifest: encryptManifestMock,
unwrapRootKey: vi.fn(),
wrapRootKey: wrapRootKeyMock,
},
}))
import { TransferProvider, defaultTransferName, useTransfers } from './TransferContext'
function TransferStateProbe() {
const { transfers } = useTransfers()
return (
<div>
{transfers.map((transfer) => (
<p key={transfer.id}>
{transfer.id}:{transfer.status}:{transfer.lastError ?? 'none'}
</p>
))}
</div>
)
}
function createFileEntry(overrides: Partial<ManifestFileEntry> = {}): ManifestFileEntry {
return {
fileId: 'file-1',
name: 'example.txt',
relativePath: 'example.txt',
mimeType: 'text/plain',
plaintextSize: 5,
modifiedAt: 1,
chunkSize: 8,
totalChunks: 1,
ciphertextSizes: [21],
noncePrefix: 'AQIDBA',
metadataStripped: false,
...overrides,
}
}
function createTransferRecord(
status: LocalTransferRecord['status'],
overrides: Partial<LocalTransferRecord> = {},
): LocalTransferRecord {
return {
id: 't1',
displayName: 'Transfer 3/19/2026',
createdAt: '2026-03-19T08:00:00.000Z',
expiresAt: '2026-03-20T08:00:00.000Z',
status,
shareUrl: 'https://example.com/t/t1#k=test',
manageToken: 'manage-token',
linkKeyBase64Url: 'AQIDBA',
rootKeyBase64Url: 'AQIDBA',
metadataStrippingEnabled: false,
clearLocalSecretsOnReady: false,
localManagementCleared: false,
totalFiles: 1,
totalBytes: 21,
uploadedBytes: 0,
sourcePersisted: true,
files: [createFileEntry()],
...overrides,
}
}
function createSourceRecord(): PersistedSourceRecord {
return {
key: 't1:file-1',
transferId: 't1',
fileId: 'file-1',
relativePath: 'example.txt',
storage: 'indexeddb',
file: new File(['hello'], 'example.txt', { type: 'text/plain', lastModified: 1 }),
name: 'example.txt',
type: 'text/plain',
lastModified: 1,
size: 5,
}
}
describe('TransferProvider', () => {
beforeEach(() => {
transfersStore.clear()
sourcesStore.clear()
listTransfersMock.mockClear()
getTransferMock.mockClear()
putTransferMock.mockClear()
getSourcesForTransferMock.mockClear()
deleteSourcesForTransferMock.mockClear()
deletePersistedTransferSourcesMock.mockClear()
loadPersistedSourceFileMock.mockClear()
resumeTransferMock.mockClear()
encryptManifestMock.mockClear()
finalizeTransferMock.mockClear()
wrapRootKeyMock.mockClear()
})
it('automatically resumes transfers interrupted by a refresh', async () => {
transfersStore.set('t1', createTransferRecord('uploading'))
sourcesStore.set('t1:file-1', createSourceRecord())
render(
<TransferProvider>
<TransferStateProbe />
</TransferProvider>,
)
await waitFor(() => {
expect(resumeTransferMock).toHaveBeenCalledWith(
't1',
'manage-token',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
})
await waitFor(() => {
expect(screen.getByText('t1:ready:none')).toBeInTheDocument()
})
expect(transfersStore.get('t1')?.status).toBe('ready')
expect(transfersStore.get('t1')?.uploadedBytes).toBe(21)
expect(wrapRootKeyMock).toHaveBeenCalled()
expect(finalizeTransferMock).toHaveBeenCalled()
})
it('automatically resumes interrupted transfers already marked for recovery', async () => {
transfersStore.set(
't1',
createTransferRecord('paused', {
lastError:
'This page was closed or refreshed. Upload will continue automatically when you return here in the same browser on this device.',
}),
)
sourcesStore.set('t1:file-1', createSourceRecord())
render(
<TransferProvider>
<TransferStateProbe />
</TransferProvider>,
)
await waitFor(() => {
expect(resumeTransferMock).toHaveBeenCalledWith(
't1',
'manage-token',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
})
await waitFor(() => {
expect(screen.getByText('t1:ready:none')).toBeInTheDocument()
})
})
it('does not auto-resume transfers that are already waiting for user action', async () => {
transfersStore.set(
't1',
createTransferRecord('paused', {
lastError: 'Upload is waiting for a manual check.',
}),
)
sourcesStore.set('t1:file-1', createSourceRecord())
render(
<TransferProvider>
<TransferStateProbe />
</TransferProvider>,
)
await waitFor(() => {
expect(
screen.getByText('t1:paused:Upload is waiting for a manual check.'),
).toBeInTheDocument()
})
expect(resumeTransferMock).not.toHaveBeenCalled()
})
})
describe('defaultTransferName', () => {
it('uses the top-level folder name for a folder selection', () => {
expect(
defaultTransferName([
createSelectedSource('Photos/IMG_0001.jpg'),
createSelectedSource('Photos/Trips/IMG_0002.jpg'),
]),
).toBe('Photos')
})
it('summarizes mixed selections with the first item name', () => {
expect(
defaultTransferName([
createSelectedSource('brief.pdf'),
createSelectedSource('notes.txt'),
createSelectedSource('screenshots/home.png'),
]),
).toBe('brief.pdf and 2 more items')
})
})
function createSelectedSource(relativePath: string) {
const fileName = relativePath.split('/').at(-1) ?? relativePath
return {
file: new File(['payload'], fileName, { type: 'application/octet-stream', lastModified: 1 }),
relativePath,
}
}