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:
@@ -336,3 +336,45 @@ describe('sanitizePath', () => {
|
||||
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload transport progress and cancellation', () => {
|
||||
class UploadXHR {
|
||||
static instances: UploadXHR[] = []
|
||||
upload = { onprogress: null as ((e: { loaded: number }) => void) | null }
|
||||
onload: (() => void) | null = null
|
||||
onabort: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
status = 200
|
||||
withCredentials = false
|
||||
contentType = 'application/json'
|
||||
open = vi.fn(); setRequestHeader = vi.fn(); send = vi.fn()
|
||||
abort = vi.fn(() => this.onabort?.())
|
||||
getResponseHeader() { return this.contentType }
|
||||
constructor() { UploadXHR.instances.push(this) }
|
||||
}
|
||||
beforeEach(() => { setAuthenticated(); UploadXHR.instances = []; vi.stubGlobal('XMLHttpRequest', UploadXHR) })
|
||||
it('sends the file bytes, escapes folder names, and waits for server acceptance after 100%', async () => {
|
||||
const controller = new AbortController(); const onProgress = vi.fn()
|
||||
const file = new File(['data'], 'a #.txt')
|
||||
let complete = false
|
||||
const job = fileBrowserClient.upload('/folder #1', file, { signal: controller.signal, onProgress }).then(() => { complete = true })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
const xhr = UploadXHR.instances[0]!
|
||||
expect(xhr.open).toHaveBeenCalledWith('POST', expect.stringMatching(/\/app\/filebrowser\/api\/resources\/folder%20%231\/a%20%23.txt\?override=true$/))
|
||||
expect(xhr.send).toHaveBeenCalledWith(file)
|
||||
xhr.upload.onprogress?.({ loaded: 4 }); expect(onProgress).toHaveBeenCalledWith(4)
|
||||
expect(complete).toBe(false)
|
||||
xhr.onload?.(); await job; expect(complete).toBe(true)
|
||||
})
|
||||
it('cancels the actual request and refuses HTML masquerading as upload success', async () => {
|
||||
const controller = new AbortController()
|
||||
const job = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: controller.signal, onProgress: vi.fn() })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
controller.abort(); await expect(job).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(UploadXHR.instances[0]!.abort).toHaveBeenCalled()
|
||||
const next = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: new AbortController().signal, onProgress: vi.fn() })
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
const xhr = UploadXHR.instances[1]!; xhr.contentType = 'text/html'; xhr.onload?.()
|
||||
await expect(next).rejects.toThrow('login page')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -201,7 +201,43 @@ class FileBrowserClient {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
async upload(dirPath: string, file: File): Promise<void> {
|
||||
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
|
||||
if (options) {
|
||||
await this.ensureAuth()
|
||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
const folder = sanitizePath(dirPath).split('/').map(encodeURIComponent).join('/').replace(/\/$/, '')
|
||||
const url = `${this.baseUrl}/api/resources${folder}/${encodeURIComponent(file.name)}?override=true`
|
||||
const send = () => new Promise<number>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const cleanup = () => options.signal.removeEventListener('abort', abort)
|
||||
const abort = () => { xhr.abort(); cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
|
||||
xhr.open('POST', url)
|
||||
xhr.withCredentials = true
|
||||
for (const [key, value] of Object.entries(this.headers())) xhr.setRequestHeader(key, value)
|
||||
xhr.upload.onprogress = (event) => options.onProgress(Math.min(file.size, event.loaded))
|
||||
xhr.onerror = () => { cleanup(); reject(new Error('Upload connection lost. Keep the companion open and check the server connection.')) }
|
||||
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
|
||||
xhr.onload = () => {
|
||||
cleanup()
|
||||
if (xhr.status === 401) { resolve(401); return }
|
||||
if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); return }
|
||||
if ((xhr.getResponseHeader('Content-Type') || '').includes('text/html')) {
|
||||
reject(new Error('File Browser returned a login page instead of accepting the upload.')); return
|
||||
}
|
||||
resolve(xhr.status)
|
||||
}
|
||||
options.signal.addEventListener('abort', abort, { once: true })
|
||||
if (options.signal.aborted) { abort(); return }
|
||||
xhr.send(file)
|
||||
})
|
||||
if (await send() === 401) {
|
||||
this._authenticated = false
|
||||
await this.ensureAuth()
|
||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
if (await send() === 401) throw new Error('Upload authentication expired. Sign in again.')
|
||||
}
|
||||
return
|
||||
}
|
||||
const sanitized = sanitizePath(dirPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
|
||||
Reference in New Issue
Block a user