index.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import 'whatwg-fetch'
  3. import nock from 'nock'
  4. import Core from '@uppy/core'
  5. import AwsS3Multipart from './index.ts'
  6. import type { Body } from './utils.ts'
  7. const KB = 1024
  8. const MB = KB * KB
  9. describe('AwsS3Multipart', () => {
  10. beforeEach(() => nock.disableNetConnect())
  11. it('Registers AwsS3Multipart upload plugin', () => {
  12. const core = new Core<any, Body>()
  13. core.use(AwsS3Multipart)
  14. // @ts-expect-error private property
  15. const pluginNames = core[Symbol.for('uppy test: getPlugins')](
  16. 'uploader',
  17. ).map((plugin: AwsS3Multipart<any, Body>) => plugin.constructor.name)
  18. expect(pluginNames).toContain('AwsS3Multipart')
  19. })
  20. describe('companionUrl assertion', () => {
  21. it('Throws an error for main functions if configured without companionUrl', () => {
  22. const core = new Core<any, Body>()
  23. core.use(AwsS3Multipart)
  24. const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
  25. const err = 'Expected a `companionUrl` option'
  26. const file = {}
  27. const opts = {}
  28. expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(err)
  29. expect(() => awsS3Multipart.opts.listParts(file, opts)).toThrow(err)
  30. expect(() =>
  31. awsS3Multipart.opts.completeMultipartUpload(file, opts),
  32. ).toThrow(err)
  33. expect(() =>
  34. awsS3Multipart.opts.abortMultipartUpload(file, opts),
  35. ).toThrow(err)
  36. expect(() => awsS3Multipart.opts.signPart(file, opts)).toThrow(err)
  37. })
  38. })
  39. describe('non-multipart upload', () => {
  40. it('should handle POST uploads', async () => {
  41. const core = new Core<any, Body>()
  42. core.use(AwsS3Multipart, {
  43. shouldUseMultipart: false,
  44. limit: 0,
  45. getUploadParameters: () => ({
  46. method: 'POST',
  47. url: 'https://bucket.s3.us-east-2.amazonaws.com/',
  48. fields: {},
  49. }),
  50. })
  51. const scope = nock(
  52. 'https://bucket.s3.us-east-2.amazonaws.com',
  53. ).defaultReplyHeaders({
  54. 'access-control-allow-headers': '*',
  55. 'access-control-allow-method': 'POST',
  56. 'access-control-allow-origin': '*',
  57. 'access-control-expose-headers': 'ETag, Location',
  58. })
  59. scope.options('/').reply(204, '')
  60. scope
  61. .post('/')
  62. .reply(201, '', { ETag: 'test', Location: 'http://example.com' })
  63. const fileSize = 1
  64. core.addFile({
  65. source: 'vi',
  66. name: 'multitest.dat',
  67. type: 'application/octet-stream',
  68. data: new File([new Uint8Array(fileSize)], '', {
  69. type: 'application/octet-stream',
  70. }),
  71. })
  72. const uploadSuccessHandler = vi.fn()
  73. core.on('upload-success', uploadSuccessHandler)
  74. await core.upload()
  75. expect(uploadSuccessHandler.mock.calls).toHaveLength(1)
  76. expect(uploadSuccessHandler.mock.calls[0][1]).toStrictEqual({
  77. body: {
  78. ETag: 'test',
  79. location: 'http://example.com',
  80. },
  81. status: 200,
  82. uploadURL: 'http://example.com',
  83. })
  84. scope.done()
  85. })
  86. })
  87. describe('without companionUrl (custom main functions)', () => {
  88. let core: Core<any, Body>
  89. let awsS3Multipart: AwsS3Multipart<any, Body>
  90. beforeEach(() => {
  91. core = new Core<any, Body>()
  92. core.use(AwsS3Multipart, {
  93. shouldUseMultipart: true,
  94. limit: 0,
  95. createMultipartUpload: vi.fn(() => {
  96. return {
  97. uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
  98. key: 'test/upload/multitest.dat',
  99. }
  100. }),
  101. completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
  102. abortMultipartUpload: vi.fn(),
  103. signPart: vi.fn(async (file, { number }) => {
  104. return {
  105. url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
  106. headers: number === 1 ? { 'Content-MD5': 'foo' } : undefined,
  107. }
  108. }),
  109. listParts: undefined as any,
  110. })
  111. awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
  112. })
  113. it('Keeps chunks marked as busy through retries until they complete', async () => {
  114. const scope = nock(
  115. 'https://bucket.s3.us-east-2.amazonaws.com',
  116. ).defaultReplyHeaders({
  117. 'access-control-allow-headers': '*',
  118. 'access-control-allow-method': 'PUT',
  119. 'access-control-allow-origin': '*',
  120. 'access-control-expose-headers': 'ETag',
  121. })
  122. const fileSize = 50 * MB
  123. scope
  124. .options((uri) => uri.includes('test/upload/multitest.dat'))
  125. .reply(200, '')
  126. scope
  127. .put(
  128. (uri) =>
  129. uri.includes('test/upload/multitest.dat') &&
  130. !uri.includes('partNumber=7'),
  131. )
  132. .reply(200, '', { ETag: 'test' })
  133. // Fail the part 7 upload once, then let it succeed
  134. let calls = 0
  135. scope
  136. .put(
  137. (uri) =>
  138. uri.includes('test/upload/multitest.dat') &&
  139. uri.includes('partNumber=7'),
  140. )
  141. .reply(() => (calls++ === 0 ? [500] : [200, '', { ETag: 'test' }]))
  142. scope.persist()
  143. // Spy on the busy/done state of the test chunk (part 7, chunk index 6)
  144. let busySpy
  145. let doneSpy
  146. awsS3Multipart.setOptions({
  147. shouldUseMultipart: true,
  148. retryDelays: [10],
  149. createMultipartUpload: vi.fn((file) => {
  150. // @ts-expect-error protected property
  151. const multipartUploader = awsS3Multipart.uploaders[file.id]!
  152. const testChunkState = multipartUploader.chunkState[6]
  153. let busy = false
  154. let done = false
  155. busySpy = vi.fn((value) => {
  156. busy = value
  157. })
  158. doneSpy = vi.fn((value) => {
  159. done = value
  160. })
  161. Object.defineProperty(testChunkState, 'busy', {
  162. get: () => busy,
  163. set: busySpy,
  164. })
  165. Object.defineProperty(testChunkState, 'done', {
  166. get: () => done,
  167. set: doneSpy,
  168. })
  169. return {
  170. uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
  171. key: 'test/upload/multitest.dat',
  172. }
  173. }),
  174. })
  175. core.addFile({
  176. source: 'vi',
  177. name: 'multitest.dat',
  178. type: 'application/octet-stream',
  179. data: new File([new Uint8Array(fileSize)], '', {
  180. type: 'application/octet-stream',
  181. }),
  182. })
  183. await core.upload()
  184. // The chunk should be marked as done once
  185. expect(doneSpy!.mock.calls.length).toEqual(1)
  186. expect(doneSpy!.mock.calls[0][0]).toEqual(true)
  187. // Any changes that set busy to false should only happen after the chunk has been marked done,
  188. // otherwise a race condition occurs (see PR #3955)
  189. const doneCallOrderNumber = doneSpy!.mock.invocationCallOrder[0]
  190. for (const [index, callArgs] of busySpy!.mock.calls.entries()) {
  191. if (callArgs[0] === false) {
  192. expect(busySpy!.mock.invocationCallOrder[index]).toBeGreaterThan(
  193. doneCallOrderNumber,
  194. )
  195. }
  196. }
  197. expect((awsS3Multipart.opts as any).signPart.mock.calls.length).toEqual(
  198. 10,
  199. )
  200. })
  201. })
  202. describe('MultipartUploader', () => {
  203. const createMultipartUpload = vi.fn(() => {
  204. return {
  205. uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
  206. key: 'test/upload/multitest.dat',
  207. }
  208. })
  209. const signPart = vi.fn(async (file, { partNumber }) => {
  210. return {
  211. url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
  212. }
  213. })
  214. const uploadPartBytes = vi.fn()
  215. afterEach(() => {
  216. vi.clearAllMocks()
  217. })
  218. it('retries uploadPartBytes when it fails once', async () => {
  219. const core = new Core<any, Body>().use(AwsS3Multipart, {
  220. shouldUseMultipart: true,
  221. createMultipartUpload,
  222. completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
  223. abortMultipartUpload: vi.fn(() => {
  224. // eslint-disable-next-line no-throw-literal
  225. throw 'should ignore'
  226. }),
  227. signPart,
  228. uploadPartBytes: uploadPartBytes.mockImplementationOnce(() =>
  229. // eslint-disable-next-line prefer-promise-reject-errors
  230. Promise.reject({ source: { status: 500 } }),
  231. ),
  232. listParts: undefined as any,
  233. })
  234. const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
  235. const fileSize = 5 * MB + 1 * MB
  236. core.addFile({
  237. source: 'vi',
  238. name: 'multitest.dat',
  239. type: 'application/octet-stream',
  240. data: new File([new Uint8Array(fileSize)], '', {
  241. type: 'application/octet-stream',
  242. }),
  243. })
  244. await core.upload()
  245. expect(awsS3Multipart.opts.uploadPartBytes.mock.calls.length).toEqual(3)
  246. })
  247. it('calls `upload-error` when uploadPartBytes fails after all retries', async () => {
  248. const core = new Core<any, Body>().use(AwsS3Multipart, {
  249. shouldUseMultipart: true,
  250. retryDelays: [10],
  251. createMultipartUpload,
  252. completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
  253. abortMultipartUpload: vi.fn(),
  254. signPart,
  255. uploadPartBytes: uploadPartBytes.mockImplementation(() =>
  256. // eslint-disable-next-line prefer-promise-reject-errors
  257. Promise.reject({ source: { status: 500 } }),
  258. ),
  259. listParts: undefined as any,
  260. })
  261. const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
  262. const fileSize = 5 * MB + 1 * MB
  263. const mock = vi.fn()
  264. core.on('upload-error', mock)
  265. core.addFile({
  266. source: 'vi',
  267. name: 'multitest.dat',
  268. type: 'application/octet-stream',
  269. data: new File([new Uint8Array(fileSize)], '', {
  270. type: 'application/octet-stream',
  271. }),
  272. })
  273. await expect(core.upload()).rejects.toEqual({ source: { status: 500 } })
  274. expect(awsS3Multipart.opts.uploadPartBytes.mock.calls.length).toEqual(3)
  275. expect(mock.mock.calls.length).toEqual(1)
  276. })
  277. })
  278. describe('dynamic companionHeader', () => {
  279. let core: Core<any, any>
  280. let awsS3Multipart: AwsS3Multipart<any, any>
  281. const oldToken = 'old token'
  282. const newToken = 'new token'
  283. beforeEach(() => {
  284. core = new Core<any, Body>()
  285. core.use(AwsS3Multipart, {
  286. companionUrl: '',
  287. companionHeaders: {
  288. authorization: oldToken,
  289. },
  290. })
  291. awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
  292. })
  293. it('companionHeader is updated before uploading file', async () => {
  294. awsS3Multipart.setOptions({
  295. companionHeaders: {
  296. authorization: newToken,
  297. },
  298. })
  299. await core.upload()
  300. // @ts-expect-error private property
  301. const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
  302. expect(
  303. client[Symbol.for('uppy test: getCompanionHeaders')]().authorization,
  304. ).toEqual(newToken)
  305. })
  306. })
  307. describe('dynamic companionHeader using setOption', () => {
  308. let core: Core<any, Body>
  309. let awsS3Multipart: AwsS3Multipart<any, Body>
  310. const newToken = 'new token'
  311. it('companionHeader is updated before uploading file', async () => {
  312. core = new Core<any, Body>()
  313. core.use(AwsS3Multipart)
  314. /* Set up preprocessor */
  315. core.addPreProcessor(() => {
  316. awsS3Multipart = core.getPlugin('AwsS3Multipart') as AwsS3Multipart<
  317. any,
  318. Body
  319. >
  320. awsS3Multipart.setOptions({
  321. companionHeaders: {
  322. authorization: newToken,
  323. },
  324. })
  325. })
  326. await core.upload()
  327. // @ts-expect-error private property
  328. const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
  329. expect(
  330. client[Symbol.for('uppy test: getCompanionHeaders')]().authorization,
  331. ).toEqual(newToken)
  332. })
  333. })
  334. describe('file metadata across custom main functions', () => {
  335. let core: Core<any, Body>
  336. const createMultipartUpload = vi.fn((file) => {
  337. core.setFileMeta(file.id, {
  338. ...file.meta,
  339. createMultipartUpload: true,
  340. })
  341. return {
  342. uploadId: 'upload1234',
  343. key: file.name,
  344. }
  345. })
  346. const signPart = vi.fn((file, partData) => {
  347. expect(file.meta.createMultipartUpload).toBe(true)
  348. core.setFileMeta(file.id, {
  349. ...file.meta,
  350. signPart: true,
  351. [`part${partData.partNumber}`]: partData.partNumber,
  352. })
  353. return {
  354. url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
  355. }
  356. })
  357. const listParts = vi.fn((file) => {
  358. expect(file.meta.createMultipartUpload).toBe(true)
  359. core.setFileMeta(file.id, {
  360. ...file.meta,
  361. listParts: true,
  362. })
  363. const partKeys = Object.keys(file.meta).filter((metaKey) =>
  364. metaKey.startsWith('part'),
  365. )
  366. return partKeys.map((metaKey) => ({
  367. PartNumber: file.meta[metaKey],
  368. ETag: metaKey,
  369. Size: 5 * MB,
  370. }))
  371. })
  372. const completeMultipartUpload = vi.fn((file) => {
  373. expect(file.meta.createMultipartUpload).toBe(true)
  374. expect(file.meta.signPart).toBe(true)
  375. for (let i = 1; i <= 10; i++) {
  376. expect(file.meta[`part${i}`]).toBe(i)
  377. }
  378. return {}
  379. })
  380. const abortMultipartUpload = vi.fn((file) => {
  381. expect(file.meta.createMultipartUpload).toBe(true)
  382. expect(file.meta.signPart).toBe(true)
  383. expect(file.meta.abortingPart).toBe(5)
  384. })
  385. beforeEach(() => {
  386. createMultipartUpload.mockClear()
  387. signPart.mockClear()
  388. listParts.mockClear()
  389. abortMultipartUpload.mockClear()
  390. completeMultipartUpload.mockClear()
  391. })
  392. it('preserves file metadata if upload is completed', async () => {
  393. core = new Core<any, Body>().use(AwsS3Multipart, {
  394. shouldUseMultipart: true,
  395. createMultipartUpload,
  396. signPart,
  397. listParts,
  398. completeMultipartUpload,
  399. abortMultipartUpload,
  400. })
  401. nock('https://bucket.s3.us-east-2.amazonaws.com')
  402. .defaultReplyHeaders({
  403. 'access-control-allow-headers': '*',
  404. 'access-control-allow-method': 'PUT',
  405. 'access-control-allow-origin': '*',
  406. 'access-control-expose-headers': 'ETag',
  407. })
  408. .put((uri) => uri.includes('test/upload/multitest.dat'))
  409. .reply(200, '', { ETag: 'test' })
  410. .persist()
  411. const fileSize = 50 * MB
  412. core.addFile({
  413. source: 'vi',
  414. name: 'multitest.dat',
  415. type: 'application/octet-stream',
  416. data: new File([new Uint8Array(fileSize)], '', {
  417. type: 'application/octet-stream',
  418. }),
  419. })
  420. await core.upload()
  421. expect(createMultipartUpload).toHaveBeenCalled()
  422. expect(signPart).toHaveBeenCalledTimes(11)
  423. expect(completeMultipartUpload).toHaveBeenCalled()
  424. })
  425. it('preserves file metadata if upload is aborted', async () => {
  426. const signPartWithAbort = vi.fn((file, partData) => {
  427. expect(file.meta.createMultipartUpload).toBe(true)
  428. if (partData.partNumber === 5) {
  429. core.setFileMeta(file.id, {
  430. ...file.meta,
  431. abortingPart: partData.partNumber,
  432. })
  433. core.removeFile(file.id)
  434. return {
  435. url: undefined as any as string,
  436. }
  437. }
  438. core.setFileMeta(file.id, {
  439. ...file.meta,
  440. signPart: true,
  441. [`part${partData.partNumber}`]: partData.partNumber,
  442. })
  443. return {
  444. url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
  445. }
  446. })
  447. core = new Core<any, Body>().use(AwsS3Multipart, {
  448. shouldUseMultipart: true,
  449. createMultipartUpload,
  450. signPart: signPartWithAbort,
  451. listParts,
  452. completeMultipartUpload,
  453. abortMultipartUpload,
  454. })
  455. nock('https://bucket.s3.us-east-2.amazonaws.com')
  456. .defaultReplyHeaders({
  457. 'access-control-allow-headers': '*',
  458. 'access-control-allow-method': 'PUT',
  459. 'access-control-allow-origin': '*',
  460. 'access-control-expose-headers': 'ETag',
  461. })
  462. .put((uri) => uri.includes('test/upload/multitest.dat'))
  463. .reply(200, '', { ETag: 'test' })
  464. .persist()
  465. const fileSize = 50 * MB
  466. core.addFile({
  467. source: 'vi',
  468. name: 'multitest.dat',
  469. type: 'application/octet-stream',
  470. data: new File([new Uint8Array(fileSize)], '', {
  471. type: 'application/octet-stream',
  472. }),
  473. })
  474. await core.upload()
  475. expect(createMultipartUpload).toHaveBeenCalled()
  476. expect(signPartWithAbort).toHaveBeenCalled()
  477. expect(abortMultipartUpload).toHaveBeenCalled()
  478. })
  479. it('preserves file metadata if upload is paused and resumed', async () => {
  480. const completeMultipartUploadAfterPause = vi.fn((file) => {
  481. expect(file.meta.createMultipartUpload).toBe(true)
  482. expect(file.meta.signPart).toBe(true)
  483. for (let i = 1; i <= 10; i++) {
  484. expect(file.meta[`part${i}`]).toBe(i)
  485. }
  486. expect(file.meta.listParts).toBe(true)
  487. return {}
  488. })
  489. const signPartWithPause = vi.fn((file, partData) => {
  490. expect(file.meta.createMultipartUpload).toBe(true)
  491. if (partData.partNumber === 3) {
  492. core.setFileMeta(file.id, {
  493. ...file.meta,
  494. abortingPart: partData.partNumber,
  495. })
  496. core.pauseResume(file.id)
  497. setTimeout(() => core.pauseResume(file.id), 500)
  498. }
  499. core.setFileMeta(file.id, {
  500. ...file.meta,
  501. signPart: true,
  502. [`part${partData.partNumber}`]: partData.partNumber,
  503. })
  504. return {
  505. url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partData.partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
  506. }
  507. })
  508. core = new Core<any, Body>().use(AwsS3Multipart, {
  509. shouldUseMultipart: true,
  510. createMultipartUpload,
  511. signPart: signPartWithPause,
  512. listParts,
  513. completeMultipartUpload: completeMultipartUploadAfterPause,
  514. abortMultipartUpload,
  515. })
  516. nock('https://bucket.s3.us-east-2.amazonaws.com')
  517. .defaultReplyHeaders({
  518. 'access-control-allow-headers': '*',
  519. 'access-control-allow-method': 'PUT',
  520. 'access-control-allow-origin': '*',
  521. 'access-control-expose-headers': 'ETag',
  522. })
  523. .put((uri) => uri.includes('test/upload/multitest.dat'))
  524. .reply(200, '', { ETag: 'test' })
  525. .persist()
  526. const fileSize = 50 * MB
  527. core.addFile({
  528. source: 'vi',
  529. name: 'multitest.dat',
  530. type: 'application/octet-stream',
  531. data: new File([new Uint8Array(fileSize)], '', {
  532. type: 'application/octet-stream',
  533. }),
  534. })
  535. await core.upload()
  536. expect(createMultipartUpload).toHaveBeenCalled()
  537. expect(signPartWithPause).toHaveBeenCalled()
  538. expect(listParts).toHaveBeenCalled()
  539. expect(completeMultipartUploadAfterPause).toHaveBeenCalled()
  540. })
  541. })
  542. })