Fix paid-file recovery, app lifecycle regressions and wallet controls
Demo images / Build & push demo images (push) Failing after 1m10s
Demo images / Build & push demo images (push) Failing after 1m10s
This commit is contained in:
@@ -231,3 +231,50 @@ describe('useCloudStore', () => {
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistent upload batch', () => {
|
||||
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
||||
it('retains the destination and byte progress across folder navigation', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true; store.currentPath = '/original'
|
||||
let release!: () => void
|
||||
mockedClient.upload.mockImplementationOnce(async (_path, _file, options) => {
|
||||
options!.onProgress(2)
|
||||
await new Promise<void>(resolve => { release = resolve })
|
||||
}).mockResolvedValueOnce(undefined)
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
const job = store.uploadFiles([new File(['abcd'], 'one'), new File(['ef'], 'two')])
|
||||
expect(store.upload?.sent).toBe(2)
|
||||
expect(store.upload?.total).toBe(6)
|
||||
await store.navigate('/elsewhere')
|
||||
expect(useCloudStore().upload?.active).toBe(true)
|
||||
release(); await job
|
||||
expect(mockedClient.upload.mock.calls.map(call => call[0])).toEqual(['/original', '/original'])
|
||||
expect(store.currentPath).toBe('/elsewhere')
|
||||
expect(store.upload).toMatchObject({ active: false, sent: 6, completed: 2, error: null })
|
||||
})
|
||||
it('aborts the active request, stops the queue and preserves completed files', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
mockedClient.upload.mockResolvedValueOnce(undefined).mockImplementationOnce((_path, _file, options) => new Promise((_resolve, reject) => {
|
||||
options!.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))
|
||||
}))
|
||||
const files = ['one', 'two', 'three'].map(name => new File(['abc'], name))
|
||||
const job = store.uploadFiles(files)
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
store.cancelUpload(); await job
|
||||
expect(mockedClient.upload).toHaveBeenCalledTimes(2)
|
||||
expect(store.upload).toMatchObject({ active: false, completed: 1, cancelled: true, error: null })
|
||||
store.dismissUpload(); expect(store.upload).toBeNull()
|
||||
})
|
||||
it('keeps failures visible and does not start a second overlapping batch', async () => {
|
||||
const store = useCloudStore(); store.authenticated = true
|
||||
mockedClient.listDirectory.mockResolvedValue([])
|
||||
let fail!: (error: Error) => void
|
||||
mockedClient.upload.mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject }))
|
||||
const file = new File(['x'], 'one')
|
||||
const job = store.uploadFiles([file]); await store.uploadFiles([file])
|
||||
expect(mockedClient.upload).toHaveBeenCalledTimes(1)
|
||||
fail(new Error('No space')); await job
|
||||
expect(store.upload).toMatchObject({ active: false, error: 'No space', completed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,41 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const authenticated = ref(false)
|
||||
const upload = ref<{ active: boolean; filename: string; destination: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
|
||||
let uploadController: AbortController | null = null
|
||||
function cancelUpload() { uploadController?.abort() }
|
||||
function dismissUpload() { if (!upload.value?.active) upload.value = null }
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (!files.length || upload.value?.active) return
|
||||
const destination = currentPath.value
|
||||
const controller = new AbortController()
|
||||
uploadController = controller
|
||||
const task = { active: true, filename: files[0]!.name, destination, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
|
||||
upload.value = task
|
||||
let completedBytes = 0
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
upload.value.filename = file.name
|
||||
await fileBrowserClient.upload(destination, file, { signal: controller.signal, onProgress: (sent) => {
|
||||
if (upload.value?.active) upload.value.sent = completedBytes + sent
|
||||
} })
|
||||
completedBytes += file.size
|
||||
upload.value.sent = completedBytes
|
||||
upload.value.completed++
|
||||
}
|
||||
} catch (error) {
|
||||
upload.value.cancelled = controller.signal.aborted
|
||||
if (!upload.value.cancelled) upload.value.error = error instanceof Error ? error.message : 'Upload failed'
|
||||
} finally {
|
||||
upload.value.active = false
|
||||
uploadController = null
|
||||
// Navigation must never redirect subsequent files into the new folder.
|
||||
pathCache.delete(destination)
|
||||
if (currentPath.value === destination) await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Per-path listing cache: re-entering a folder paints the last listing
|
||||
// immediately (no spinner) while the fresh listing loads behind it.
|
||||
const pathCache = new Map<string, FileBrowserItem[]>()
|
||||
@@ -131,6 +166,7 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
upload, uploadFiles, cancelUpload, dismissUpload,
|
||||
currentPath,
|
||||
items,
|
||||
loading,
|
||||
|
||||
Reference in New Issue
Block a user