Skip to content

Commit 7eb9d94

Browse files
committed
Add test coverage for tus.ts
1 parent ffbcd4b commit 7eb9d94

1 file changed

Lines changed: 263 additions & 0 deletions

File tree

test/unit/tus.test.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import type { stat as NodeStat } from 'node:fs/promises'
2+
import { PassThrough } from 'node:stream'
3+
import type { OnSuccessPayload, UploadOptions } from 'tus-js-client'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
let startBehavior: (options: UploadOptions) => void
7+
8+
type StatPathArg = Parameters<typeof NodeStat>[0]
9+
interface MockStatResult {
10+
size: number
11+
}
12+
type MockStatFn = (path: StatPathArg) => Promise<MockStatResult>
13+
14+
const createStatResult = (size: number): MockStatResult => ({ size })
15+
16+
const { statMock } = vi.hoisted(() => ({
17+
statMock: vi.fn<MockStatFn>(async () => createStatResult(0)),
18+
}))
19+
20+
vi.mock('node:fs/promises', () => ({
21+
stat: statMock as unknown as typeof NodeStat,
22+
}))
23+
24+
const { uploadCtor } = vi.hoisted(() => ({
25+
uploadCtor: vi.fn((_stream: unknown, options: UploadOptions) => ({
26+
start: vi.fn(() => {
27+
if (!startBehavior) {
28+
throw new Error('startBehavior not configured for test')
29+
}
30+
startBehavior(options)
31+
}),
32+
})),
33+
}))
34+
35+
vi.mock('tus-js-client', () => ({
36+
Upload: uploadCtor,
37+
}))
38+
39+
const { pMapSpy } = vi.hoisted(() => ({
40+
pMapSpy: vi.fn(
41+
async (
42+
iterable: Iterable<unknown>,
43+
mapper: (item: unknown) => unknown | Promise<unknown>,
44+
_options?: { concurrency?: number },
45+
) => {
46+
const items = Array.isArray(iterable) ? iterable : Array.from(iterable)
47+
const results: unknown[] = []
48+
for (const item of items) {
49+
results.push(await mapper(item))
50+
}
51+
return results
52+
},
53+
),
54+
}))
55+
56+
vi.mock('p-map', () => ({
57+
default: pMapSpy,
58+
}))
59+
60+
import type { AssemblyStatus } from '../../src/alphalib/types/assemblyStatus.js'
61+
import { sendTusRequest } from '../../src/tus.js'
62+
63+
const baseAssembly = {
64+
assembly_ssl_url: 'https://assembly.example.com',
65+
tus_url: 'https://tus.example.com/files/',
66+
} as AssemblyStatus
67+
68+
describe('sendTusRequest', () => {
69+
beforeEach(() => {
70+
statMock.mockReset()
71+
statMock.mockImplementation(async () => createStatResult(0))
72+
uploadCtor.mockClear()
73+
pMapSpy.mockClear()
74+
startBehavior = (options) => {
75+
options.onSuccess?.({} as OnSuccessPayload)
76+
}
77+
})
78+
79+
it('reports aggregate progress when every stream has a known size', async () => {
80+
const firstPath = '/tmp/first'
81+
const secondPath = '/tmp/second'
82+
const sizesByPath: Record<string, number> = {
83+
[firstPath]: 1024,
84+
[secondPath]: 2048,
85+
}
86+
87+
statMock.mockImplementation(async (path: StatPathArg) => {
88+
const key = typeof path === 'string' ? path : path.toString()
89+
return createStatResult(sizesByPath[key] ?? 0)
90+
})
91+
92+
const onProgress = vi.fn()
93+
94+
startBehavior = (options) => {
95+
const field = options.metadata?.fieldname as 'first' | 'second'
96+
const total = field === 'first' ? sizesByPath[firstPath] : sizesByPath[secondPath]
97+
const uploaded = total
98+
options.onProgress?.(uploaded, total)
99+
options.onSuccess?.({} as OnSuccessPayload)
100+
}
101+
102+
await sendTusRequest({
103+
streamsMap: {
104+
first: { path: firstPath, stream: new PassThrough() },
105+
second: { path: secondPath, stream: new PassThrough() },
106+
},
107+
assembly: baseAssembly,
108+
requestedChunkSize: 5_242_880,
109+
uploadConcurrency: 2,
110+
onProgress,
111+
})
112+
113+
expect(onProgress).toHaveBeenCalledTimes(2)
114+
expect(onProgress).toHaveBeenNthCalledWith(1, {
115+
uploadedBytes: 1024,
116+
totalBytes: 3072,
117+
})
118+
expect(onProgress).toHaveBeenNthCalledWith(2, {
119+
uploadedBytes: 3072,
120+
totalBytes: 3072,
121+
})
122+
123+
expect(statMock).toHaveBeenCalledWith(firstPath)
124+
expect(statMock).toHaveBeenCalledWith(secondPath)
125+
126+
expect(uploadCtor).toHaveBeenCalledTimes(2)
127+
const uploadOptions = uploadCtor.mock.calls.map(([, options]) => options)
128+
129+
expect(uploadOptions[0]).toMatchObject({
130+
chunkSize: 5_242_880,
131+
uploadSize: 1024,
132+
metadata: expect.objectContaining({
133+
assembly_url: baseAssembly.assembly_ssl_url,
134+
fieldname: 'first',
135+
filename: 'first',
136+
}),
137+
})
138+
expect(uploadOptions[1]).toMatchObject({
139+
chunkSize: 5_242_880,
140+
uploadSize: 2048,
141+
metadata: expect.objectContaining({
142+
fieldname: 'second',
143+
filename: 'second',
144+
}),
145+
})
146+
147+
expect(pMapSpy.mock.calls[1]?.[2]?.concurrency).toBe(2)
148+
})
149+
150+
it('emits incremental aggregate progress as each stream advances', async () => {
151+
const firstPath = '/tmp/inc-first'
152+
const secondPath = '/tmp/inc-second'
153+
const sizesByPath: Record<string, number> = {
154+
[firstPath]: 200,
155+
[secondPath]: 100,
156+
}
157+
158+
statMock.mockImplementation(async (path: StatPathArg) => {
159+
const key = typeof path === 'string' ? path : path.toString()
160+
return createStatResult(sizesByPath[key] ?? 0)
161+
})
162+
163+
const onProgress = vi.fn()
164+
165+
const progressByField: Record<string, Array<{ uploaded: number; total: number }>> = {
166+
first: [
167+
{ uploaded: 50, total: sizesByPath[firstPath] },
168+
{ uploaded: sizesByPath[firstPath], total: sizesByPath[firstPath] },
169+
],
170+
second: [
171+
{ uploaded: 30, total: sizesByPath[secondPath] },
172+
{ uploaded: 60, total: sizesByPath[secondPath] },
173+
{ uploaded: sizesByPath[secondPath], total: sizesByPath[secondPath] },
174+
],
175+
}
176+
177+
startBehavior = (options) => {
178+
const field = options.metadata?.fieldname as keyof typeof progressByField
179+
const events = progressByField[field]
180+
if (!events) {
181+
throw new Error(`Unexpected field ${String(field)}`)
182+
}
183+
for (const { uploaded, total } of events) {
184+
options.onProgress?.(uploaded, total)
185+
}
186+
options.onSuccess?.({} as OnSuccessPayload)
187+
}
188+
189+
await sendTusRequest({
190+
streamsMap: {
191+
first: { path: firstPath, stream: new PassThrough() },
192+
second: { path: secondPath, stream: new PassThrough() },
193+
},
194+
assembly: baseAssembly,
195+
requestedChunkSize: 1_048_576,
196+
uploadConcurrency: 2,
197+
onProgress,
198+
})
199+
200+
expect(onProgress.mock.calls.map(([payload]) => payload)).toEqual([
201+
{ uploadedBytes: 50, totalBytes: 300 },
202+
{ uploadedBytes: 200, totalBytes: 300 },
203+
{ uploadedBytes: 230, totalBytes: 300 },
204+
{ uploadedBytes: 260, totalBytes: 300 },
205+
{ uploadedBytes: 300, totalBytes: 300 },
206+
])
207+
})
208+
209+
it('configures deferred length uploads when stream size is unknown', async () => {
210+
const onProgress = vi.fn()
211+
212+
startBehavior = (options) => {
213+
options.onProgress?.(123, Number.POSITIVE_INFINITY)
214+
options.onSuccess?.({} as OnSuccessPayload)
215+
}
216+
217+
await sendTusRequest({
218+
streamsMap: {
219+
raw: { stream: new PassThrough() },
220+
},
221+
assembly: baseAssembly,
222+
requestedChunkSize: Number.POSITIVE_INFINITY,
223+
uploadConcurrency: 1,
224+
onProgress,
225+
})
226+
227+
expect(onProgress).toHaveBeenCalledWith({
228+
uploadedBytes: 123,
229+
totalBytes: undefined,
230+
})
231+
232+
expect(statMock).not.toHaveBeenCalled()
233+
234+
expect(uploadCtor).toHaveBeenCalledTimes(1)
235+
const [, options] = uploadCtor.mock.calls[0]
236+
expect(options.uploadLengthDeferred).toBe(true)
237+
expect(options.chunkSize).toBe(50_000_000)
238+
expect(options.metadata).toMatchObject({
239+
fieldname: 'raw',
240+
filename: 'raw',
241+
})
242+
})
243+
244+
it('rejects when the Assembly is missing the SSL URL', async () => {
245+
statMock.mockResolvedValue({ size: 2048 })
246+
247+
await expect(
248+
sendTusRequest({
249+
streamsMap: {
250+
broken: { path: '/tmp/broken', stream: new PassThrough() },
251+
},
252+
assembly: {
253+
tus_url: baseAssembly.tus_url,
254+
} as AssemblyStatus,
255+
requestedChunkSize: 1_048_576,
256+
uploadConcurrency: 1,
257+
onProgress: () => {},
258+
}),
259+
).rejects.toThrow('assembly_ssl_url is not present in the assembly status')
260+
261+
expect(uploadCtor).not.toHaveBeenCalled()
262+
})
263+
})

0 commit comments

Comments
 (0)