@gentleduck/upload 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -90
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +54 -39
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +8 -4
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +126 -0
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -222
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { createFetchTransport } from '../core/contracts/transport'
|
|
3
|
+
import { UploadAbortError } from '../core/contracts/transport/transport.libs'
|
|
4
|
+
|
|
5
|
+
const URL_OK = 'https://up.example.com/o'
|
|
6
|
+
|
|
7
|
+
function stubFetch(impl: (url: string, init: RequestInit) => Promise<Response> | Response) {
|
|
8
|
+
const fn = vi.fn(impl)
|
|
9
|
+
vi.stubGlobal('fetch', fn)
|
|
10
|
+
return fn
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.unstubAllGlobals()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('createFetchTransport.put', () => {
|
|
18
|
+
test('PUTs the body and returns the ETag', async () => {
|
|
19
|
+
const fetchMock = stubFetch(() => new Response(null, { status: 200, headers: { ETag: '"deadbeef"' } }))
|
|
20
|
+
const t = createFetchTransport()
|
|
21
|
+
const onProgress = vi.fn()
|
|
22
|
+
const out = await t.put({
|
|
23
|
+
url: URL_OK,
|
|
24
|
+
body: new Blob([new Uint8Array(10)]),
|
|
25
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
26
|
+
signal: new AbortController().signal,
|
|
27
|
+
onProgress,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(out.etag).toBe('deadbeef')
|
|
31
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
32
|
+
expect(url).toBe(URL_OK)
|
|
33
|
+
expect(init.method).toBe('PUT')
|
|
34
|
+
// Terminal progress tick.
|
|
35
|
+
expect(onProgress).toHaveBeenCalledWith(10, 10)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('rejects non-2xx with the status', async () => {
|
|
39
|
+
stubFetch(() => new Response('nope', { status: 500 }))
|
|
40
|
+
const t = createFetchTransport()
|
|
41
|
+
await expect(t.put({ url: URL_OK, body: new Blob(['x']), signal: new AbortController().signal })).rejects.toThrow(
|
|
42
|
+
/status 500/,
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('rejects a relative URL', async () => {
|
|
47
|
+
stubFetch(() => new Response(null, { status: 200 }))
|
|
48
|
+
const t = createFetchTransport()
|
|
49
|
+
await expect(
|
|
50
|
+
t.put({ url: '/relative', body: new Blob(['x']), signal: new AbortController().signal }),
|
|
51
|
+
).rejects.toThrow(/absolute URL/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('maps an aborted signal to UploadAbortError', async () => {
|
|
55
|
+
stubFetch(() => new Response(null, { status: 200 }))
|
|
56
|
+
const controller = new AbortController()
|
|
57
|
+
controller.abort('cancel')
|
|
58
|
+
const t = createFetchTransport()
|
|
59
|
+
await expect(t.put({ url: URL_OK, body: new Blob(['x']), signal: controller.signal })).rejects.toBeInstanceOf(
|
|
60
|
+
UploadAbortError,
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('createFetchTransport.postForm', () => {
|
|
66
|
+
test('sends multipart form data', async () => {
|
|
67
|
+
const fetchMock = stubFetch(() => new Response(null, { status: 204 }))
|
|
68
|
+
const t = createFetchTransport()
|
|
69
|
+
await t.postForm({
|
|
70
|
+
url: URL_OK,
|
|
71
|
+
fields: { key: 'v', policy: 'p' },
|
|
72
|
+
file: new File(['abc'], 'a.txt'),
|
|
73
|
+
signal: new AbortController().signal,
|
|
74
|
+
})
|
|
75
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
76
|
+
expect(init.method).toBe('POST')
|
|
77
|
+
expect(init.body).toBeInstanceOf(FormData)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('createFetchTransport.get', () => {
|
|
82
|
+
test('streams the body and reports incremental progress', async () => {
|
|
83
|
+
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
|
84
|
+
stubFetch(
|
|
85
|
+
() =>
|
|
86
|
+
new Response(payload, {
|
|
87
|
+
status: 200,
|
|
88
|
+
headers: { 'Content-Length': String(payload.length), 'Content-Type': 'application/octet-stream' },
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
const t = createFetchTransport()
|
|
92
|
+
const onProgress = vi.fn()
|
|
93
|
+
const out = await t.get?.({ url: URL_OK, signal: new AbortController().signal, onProgress })
|
|
94
|
+
expect(out?.status).toBe(200)
|
|
95
|
+
const bytes = new Uint8Array(await out!.blob.arrayBuffer())
|
|
96
|
+
expect(Array.from(bytes)).toEqual(Array.from(payload))
|
|
97
|
+
// Final progress equals total.
|
|
98
|
+
const last = onProgress.mock.calls.at(-1)
|
|
99
|
+
expect(last?.[0]).toBe(payload.length)
|
|
100
|
+
expect(last?.[1]).toBe(payload.length)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('rejects a failed download', async () => {
|
|
104
|
+
stubFetch(() => new Response('nope', { status: 404 }))
|
|
105
|
+
const t = createFetchTransport()
|
|
106
|
+
await expect(t.get?.({ url: URL_OK, signal: new AbortController().signal })).rejects.toThrow(/status 404/)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { makeAbortError, normalizeAbortReason, UploadAbortError } from '../core/contracts/transport/transport.libs'
|
|
3
|
+
|
|
4
|
+
describe('normalizeAbortReason', () => {
|
|
5
|
+
test('passes bare strings through when valid', () => {
|
|
6
|
+
expect(normalizeAbortReason('pause')).toBe('pause')
|
|
7
|
+
expect(normalizeAbortReason('cancel')).toBe('cancel')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('unknown bare strings degrade to "unknown"', () => {
|
|
11
|
+
expect(normalizeAbortReason('shutdown')).toBe('unknown')
|
|
12
|
+
expect(normalizeAbortReason(undefined)).toBe('unknown')
|
|
13
|
+
expect(normalizeAbortReason(null)).toBe('unknown')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('extracts .reason and .kind from object payloads', () => {
|
|
17
|
+
expect(normalizeAbortReason({ reason: 'pause' })).toBe('pause')
|
|
18
|
+
expect(normalizeAbortReason({ kind: 'cancel' })).toBe('cancel')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('arrays do not crash and degrade to unknown', () => {
|
|
22
|
+
expect(normalizeAbortReason(['pause'])).toBe('unknown')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('UploadAbortError / makeAbortError', () => {
|
|
27
|
+
test('exposes a stable code', () => {
|
|
28
|
+
const e = makeAbortError('pause')
|
|
29
|
+
expect(e).toBeInstanceOf(UploadAbortError)
|
|
30
|
+
expect(e.code).toBe('aborted')
|
|
31
|
+
expect(e.reason).toBe('pause')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('defaults unknown reasons', () => {
|
|
35
|
+
expect(makeAbortError(undefined).reason).toBe('unknown')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('structured payloads with a known reason still normalize to pause/cancel', () => {
|
|
39
|
+
expect(new UploadAbortError({ reason: 'cancel', extra: 'meta' }).reason).toBe('cancel')
|
|
40
|
+
expect(new UploadAbortError({ kind: 'pause' }).reason).toBe('pause')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { createXHRTransport } from '../core/contracts/transport'
|
|
3
|
+
import { UploadAbortError } from '../core/contracts/transport/transport.libs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal `XMLHttpRequest` mock. Captures method/url/headers/body and exposes
|
|
7
|
+
* hooks to simulate `onload` / `onerror` / `onabort` and per-progress events.
|
|
8
|
+
*/
|
|
9
|
+
class MockXHR {
|
|
10
|
+
static instances: MockXHR[] = []
|
|
11
|
+
|
|
12
|
+
method = ''
|
|
13
|
+
url = ''
|
|
14
|
+
body: unknown = null
|
|
15
|
+
headers: Record<string, string> = {}
|
|
16
|
+
status = 0
|
|
17
|
+
upload = { onprogress: null as ((e: ProgressEvent) => void) | null }
|
|
18
|
+
onload: ((e: Event) => void) | null = null
|
|
19
|
+
onerror: ((e: Event) => void) | null = null
|
|
20
|
+
onabort: ((e: Event) => void) | null = null
|
|
21
|
+
responseHeadersRaw = ''
|
|
22
|
+
responseHeaders: Record<string, string> = {}
|
|
23
|
+
|
|
24
|
+
constructor() {
|
|
25
|
+
MockXHR.instances.push(this)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
open(method: string, url: string) {
|
|
29
|
+
this.method = method
|
|
30
|
+
this.url = url
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setRequestHeader(name: string, value: string) {
|
|
34
|
+
this.headers[name.toLowerCase()] = value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
send(body: unknown) {
|
|
38
|
+
this.body = body
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
abort() {
|
|
42
|
+
this.onabort?.(new Event('abort'))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getResponseHeader(name: string): string | null {
|
|
46
|
+
return this.responseHeaders[name.toLowerCase()] ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getAllResponseHeaders(): string {
|
|
50
|
+
return this.responseHeadersRaw
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Test helpers
|
|
54
|
+
fireSuccess(status: number, headers: Record<string, string> = {}) {
|
|
55
|
+
this.status = status
|
|
56
|
+
this.responseHeaders = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
|
|
57
|
+
this.responseHeadersRaw = Object.entries(headers)
|
|
58
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
59
|
+
.join('\r\n')
|
|
60
|
+
this.onload?.(new Event('load'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fireError(status = 0) {
|
|
64
|
+
this.status = status
|
|
65
|
+
this.onerror?.(new Event('error'))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const originalXHR = (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
MockXHR.instances = []
|
|
73
|
+
;(globalThis as unknown as { XMLHttpRequest: typeof MockXHR }).XMLHttpRequest = MockXHR
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
if (originalXHR) (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = originalXHR
|
|
78
|
+
else delete (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('createXHRTransport.put', () => {
|
|
82
|
+
test('resolves on 2xx and parses ETag', async () => {
|
|
83
|
+
const t = createXHRTransport()
|
|
84
|
+
const ctl = new AbortController()
|
|
85
|
+
const promise = t.put({
|
|
86
|
+
url: 'https://example.com/part',
|
|
87
|
+
body: new Blob(['x']),
|
|
88
|
+
signal: ctl.signal,
|
|
89
|
+
headers: { 'x-foo': 'bar' },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const xhr = MockXHR.instances[0]!
|
|
93
|
+
expect(xhr.method).toBe('PUT')
|
|
94
|
+
expect(xhr.url).toBe('https://example.com/part')
|
|
95
|
+
expect(xhr.headers['x-foo']).toBe('bar')
|
|
96
|
+
|
|
97
|
+
xhr.fireSuccess(200, { ETag: '"abc"' })
|
|
98
|
+
const result = await promise
|
|
99
|
+
expect(result.etag).toBe('abc')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('rejects with a status-tagged Error on non-2xx status', async () => {
|
|
103
|
+
const t = createXHRTransport()
|
|
104
|
+
const ctl = new AbortController()
|
|
105
|
+
const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
|
|
106
|
+
|
|
107
|
+
const xhr = MockXHR.instances[0]!
|
|
108
|
+
xhr.fireSuccess(503)
|
|
109
|
+
await expect(promise).rejects.toThrow(/status 503/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('rejects with a CORS-tagged Error when status=0', async () => {
|
|
113
|
+
const t = createXHRTransport()
|
|
114
|
+
const ctl = new AbortController()
|
|
115
|
+
const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
|
|
116
|
+
|
|
117
|
+
const xhr = MockXHR.instances[0]!
|
|
118
|
+
xhr.fireError(0)
|
|
119
|
+
await expect(promise).rejects.toThrow(/CORS/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('rejects with UploadAbortError when the signal aborts', async () => {
|
|
123
|
+
const t = createXHRTransport()
|
|
124
|
+
const ctl = new AbortController()
|
|
125
|
+
const promise = t.put({ url: 'https://example.com/part', body: new Blob(['x']), signal: ctl.signal })
|
|
126
|
+
|
|
127
|
+
ctl.abort('cancel')
|
|
128
|
+
await expect(promise).rejects.toBeInstanceOf(UploadAbortError)
|
|
129
|
+
await expect(promise).rejects.toMatchObject({ code: 'aborted', reason: 'cancel' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('rejects non-absolute URLs', async () => {
|
|
133
|
+
const t = createXHRTransport()
|
|
134
|
+
const ctl = new AbortController()
|
|
135
|
+
await expect(t.put({ url: '/relative/path', body: new Blob(['x']), signal: ctl.signal })).rejects.toThrow(
|
|
136
|
+
/absolute URL/,
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('forwards upload progress events', async () => {
|
|
141
|
+
const t = createXHRTransport()
|
|
142
|
+
const ctl = new AbortController()
|
|
143
|
+
const onProgress = vi.fn()
|
|
144
|
+
const promise = t.put({
|
|
145
|
+
url: 'https://example.com/part',
|
|
146
|
+
body: new Blob(['x']),
|
|
147
|
+
signal: ctl.signal,
|
|
148
|
+
onProgress,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const xhr = MockXHR.instances[0]!
|
|
152
|
+
xhr.upload.onprogress?.({ lengthComputable: true, loaded: 5, total: 10 } as ProgressEvent)
|
|
153
|
+
expect(onProgress).toHaveBeenCalledWith(5, 10)
|
|
154
|
+
|
|
155
|
+
xhr.fireSuccess(200)
|
|
156
|
+
await promise
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('createXHRTransport.postForm', () => {
|
|
161
|
+
test('builds FormData with fields + file and POSTs', async () => {
|
|
162
|
+
const t = createXHRTransport()
|
|
163
|
+
const ctl = new AbortController()
|
|
164
|
+
const file = new File(['payload'], 'a.txt')
|
|
165
|
+
const promise = t.postForm({
|
|
166
|
+
url: 'https://example.com/upload',
|
|
167
|
+
fields: { key: 'avatar/a.txt', acl: 'private' },
|
|
168
|
+
file,
|
|
169
|
+
signal: ctl.signal,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const xhr = MockXHR.instances[0]!
|
|
173
|
+
expect(xhr.method).toBe('POST')
|
|
174
|
+
expect(xhr.body).toBeInstanceOf(FormData)
|
|
175
|
+
const form = xhr.body as FormData
|
|
176
|
+
expect(form.get('key')).toBe('avatar/a.txt')
|
|
177
|
+
expect(form.get('acl')).toBe('private')
|
|
178
|
+
|
|
179
|
+
xhr.fireSuccess(204)
|
|
180
|
+
await promise
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { UploadEngineError } from '../errors'
|
|
3
|
+
|
|
4
|
+
const MALICIOUS_FILENAME = '<img src=x onerror=alert(1)>.png'
|
|
5
|
+
|
|
6
|
+
describe('UploadEngineError', () => {
|
|
7
|
+
test('message is static, does NOT contain the raw filename', () => {
|
|
8
|
+
const err = new UploadEngineError('upload_failed', { context: { filename: MALICIOUS_FILENAME } })
|
|
9
|
+
expect(err.message).not.toContain(MALICIOUS_FILENAME)
|
|
10
|
+
expect(err.message).not.toContain('<img')
|
|
11
|
+
expect(err.message).toBe('upload transfer failed')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('context.filename carries the raw filename intact', () => {
|
|
15
|
+
const err = new UploadEngineError('upload_failed', { context: { filename: MALICIOUS_FILENAME } })
|
|
16
|
+
expect(err.context?.['filename']).toBe(MALICIOUS_FILENAME)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('is an instance of Error and UploadEngineError; code is preserved', () => {
|
|
20
|
+
const err = new UploadEngineError('intent_failed')
|
|
21
|
+
expect(err).toBeInstanceOf(Error)
|
|
22
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
23
|
+
expect(err.code).toBe('intent_failed')
|
|
24
|
+
expect(err.name).toBe('UploadEngineError')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('cause is preserved on the ES2022 cause field', () => {
|
|
28
|
+
const original = new Error('boom')
|
|
29
|
+
const err = new UploadEngineError('upload_failed', { cause: original })
|
|
30
|
+
expect(err.cause).toBe(original)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('unknown code falls back to a generic static message', () => {
|
|
34
|
+
const err = new UploadEngineError('something_new')
|
|
35
|
+
expect(err.message).toBe('upload error (something_new)')
|
|
36
|
+
expect(err.code).toBe('something_new')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('engine handlers filename-in-message inverse assertion', () => {
|
|
41
|
+
// The legacy contract was: `error.message.includes(filename)`. The new
|
|
42
|
+
// contract is the opposite: the filename MUST NOT appear in `message`. The
|
|
43
|
+
// handlers no longer interpolate filename into `message`; verifying that by
|
|
44
|
+
// exercising the handlers directly is covered by the integration suite, but
|
|
45
|
+
// we keep the contract pinned here so future refactors stay safe.
|
|
46
|
+
test('a structured error keeps the message clean and surfaces filename only on context', () => {
|
|
47
|
+
const base = { code: 'unknown' as const, message: 'Unknown error', retryable: false }
|
|
48
|
+
const error = {
|
|
49
|
+
...base,
|
|
50
|
+
context: { filename: MALICIOUS_FILENAME, size: 12, purpose: 'avatar' },
|
|
51
|
+
}
|
|
52
|
+
expect(error.message).not.toContain(MALICIOUS_FILENAME)
|
|
53
|
+
expect(error.message).not.toContain('<img')
|
|
54
|
+
expect(error.context['filename']).toBe(MALICIOUS_FILENAME)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
import {
|
|
59
|
+
UploadAbortError,
|
|
60
|
+
UploadAuthError,
|
|
61
|
+
UploadHttpError,
|
|
62
|
+
UploadNetworkError,
|
|
63
|
+
UploadRateLimitError,
|
|
64
|
+
UploadServerError,
|
|
65
|
+
UploadStrategyMissingError,
|
|
66
|
+
UploadTimeoutError,
|
|
67
|
+
UploadUnknownError,
|
|
68
|
+
UploadValidationError,
|
|
69
|
+
} from '../errors'
|
|
70
|
+
|
|
71
|
+
describe('UploadEngineError subclasses', () => {
|
|
72
|
+
test('UploadValidationError', () => {
|
|
73
|
+
const reason = { code: 'empty_file' as const }
|
|
74
|
+
const err = new UploadValidationError(reason)
|
|
75
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
76
|
+
expect(err.code).toBe('validation_failed')
|
|
77
|
+
expect(err.reason).toBe(reason)
|
|
78
|
+
expect(err.retryable).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('UploadStrategyMissingError', () => {
|
|
82
|
+
const err = new UploadStrategyMissingError('custom')
|
|
83
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
84
|
+
expect(err.code).toBe('strategy_missing')
|
|
85
|
+
expect(err.strategy).toBe('custom')
|
|
86
|
+
expect(err.retryable).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('UploadAbortError', () => {
|
|
90
|
+
const err = new UploadAbortError('pause')
|
|
91
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
92
|
+
expect(err.code).toBe('aborted')
|
|
93
|
+
expect(err.reason).toBe('pause')
|
|
94
|
+
expect(err.retryable).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('UploadNetworkError', () => {
|
|
98
|
+
const err = new UploadNetworkError()
|
|
99
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
100
|
+
expect(err.code).toBe('network')
|
|
101
|
+
expect(err.retryable).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('UploadHttpError', () => {
|
|
105
|
+
const err = new UploadHttpError(500, 'Internal Server Error', { statusText: 'Server Error' })
|
|
106
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
107
|
+
expect(err.code).toBe('http')
|
|
108
|
+
expect(err.status).toBe(500)
|
|
109
|
+
expect(err.statusText).toBe('Server Error')
|
|
110
|
+
expect(err.retryable).toBe(true)
|
|
111
|
+
|
|
112
|
+
const err400 = new UploadHttpError(400, 'Bad Request')
|
|
113
|
+
expect(err400.retryable).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('UploadTimeoutError', () => {
|
|
117
|
+
const err = new UploadTimeoutError()
|
|
118
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
119
|
+
expect(err.code).toBe('timeout')
|
|
120
|
+
expect(err.retryable).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('UploadAuthError', () => {
|
|
124
|
+
const err = new UploadAuthError()
|
|
125
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
126
|
+
expect(err.code).toBe('auth')
|
|
127
|
+
expect(err.retryable).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('UploadRateLimitError', () => {
|
|
131
|
+
const err = new UploadRateLimitError('Too many requests', { retryAfterMs: 5000 })
|
|
132
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
133
|
+
expect(err.code).toBe('rate_limit')
|
|
134
|
+
expect(err.retryAfterMs).toBe(5000)
|
|
135
|
+
expect(err.retryable).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('UploadServerError', () => {
|
|
139
|
+
const err = new UploadServerError('DB Error', { serverCode: 'DB_ERROR' })
|
|
140
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
141
|
+
expect(err.code).toBe('server')
|
|
142
|
+
expect(err.serverCode).toBe('DB_ERROR')
|
|
143
|
+
expect(err.retryable).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('UploadUnknownError', () => {
|
|
147
|
+
const err = new UploadUnknownError('Something exploded')
|
|
148
|
+
expect(err).toBeInstanceOf(UploadEngineError)
|
|
149
|
+
expect(err.code).toBe('unknown')
|
|
150
|
+
expect(err.retryable).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
})
|