Skip to content

Commit e36ee53

Browse files
fix(service-storage)!: the chunked completion assembles the parts the upload holds, and a re-sent chunk is counted once (#22313) (#22330)
Fixes #22313 Clause-②: no (narrowing) ## Summary A chunked upload now completes with the file it was sent, or not at all. - **The completion door** (`POST /storage/upload/chunked/:uploadId/complete`) assembles the upload's own record of the chunks it holds: every chunk the chunk door stored, with the eTag the backend answered for it. It no longer assembles the request's `parts` list. The list is checked against the record, and the record is checked against the declared `totalChunks` / `totalSize`. When any check fails, the door answers **`409 RESOURCE_CONFLICT`** before anything is assembled. `error.details` names the chunk indexes: `missingChunks`, `unexpectedChunks`, `unheldListedChunks` and `mismatchedChunks`, plus `totalChunks`, `totalSize` and `heldBytes`. The session stays `in_progress` and the file stays `pending`, so the uploader can send what is missing and complete again. - **The chunk door** (`PUT …/chunk/:chunkIndex`): a re-sent index replaces its slot in `sys_upload_session.parts`. `uploaded_chunks` counts distinct chunks. `uploaded_size` is the sum of each chunk's latest size, which uses the per-part size PR #22311 records. A session whose record predates those sizes keeps its running total, as PR #22311 judges it. - **`packages/client` is unchanged.** Because the door completes from the stored parts, the SDK's `resumeUpload` is correct as it is: it lists only the parts its own pass sent. The pins below drive the doors in exactly that order. ## Measured: before (main `4e4111ca`) and after The pins were committed first (`ab583f369d`, `4508df0ec0`) and run on unfixed main before `storage-routes.ts` was touched. | Pin | main `4e4111ca` | this branch | |---|---|---| | Resumed 2-chunk upload (chunk 0 = 5 MiB stored in a first pass; the resume sends chunk 1 = 10 B and completes listing only chunk 1) | `200`, `size` 5242890, stored object **10 bytes** (unit and over HTTP) | `200`, stored object 5242890 bytes, byte-equal to chunk0 + chunk1 (unit, and read back through `GET /storage/files/:id/url` over HTTP) | | Upload that never received chunk 0, completed | `200` | `409 RESOURCE_CONFLICT`, `details.missingChunks` = `[0]`; session `in_progress`; the backend assembly is not called | | Chunk 0 (600 KiB) of a 1 MiB session sent twice, then progress read | `uploadedSize` 1228800, `uploadedChunks` 2, `percentComplete` 100 | 614400 / 1 / 59, identical to the reading after one send | | CONTROL: a clean one-pass upload listing every part | `200`, full file | `200`, full file (unchanged) | ## Why the door completes from stored parts rather than refusing every short list (dispatch H2 / H4) Two measurements decided this. - **The server holds what assembly needs.** The record stores, for every chunk, the eTag the backend itself answered at upload time. S3's `CompleteMultipartUpload` needs part numbers and those eTags; the local adapter reads part files by number and ignores eTags. So the server can fill any part the request omits. - **The client cannot list what the record holds.** The progress answer (`UploadProgressSchema`) carries no parts or eTags, so `resumeUpload` cannot list the parts an earlier pass sent. Exposing them would be a `packages/spec` change, which is fenced. Refusing every list that omits a stored part would therefore refuse every real SDK resume. Assembling the record still refuses a short assembly. The door refuses any upload whose record lacks a declared chunk or does not add up to the declared size. Triage's pin is "completes with the full file or is refused", and both halves hold. ## Files outside the dispatch's file list (a declared fence deviation) - `packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts` and `storage-upload-organization-change.wall.test.ts`: three POSITIVE legs (one, plus two) completed a 10-byte chunked upload that held none of its bytes and expected `200`. That `200` is this defect, and the door now refuses it. Each leg now sends the declared 10 bytes as chunk 0 before completing. The refusals these files pin (ownership `403`, organization-change `409`) run before the new check and are unchanged. - In `service-storage`, five existing tests did the same zero-byte completion and were updated the same way. `tenant-audit-update-delete-half-repairs.test.ts`'s recording engine double now applies a by-id `update` to its row, as the real engine does. Without that, the completion could not read back the record the chunk door wrote. ## Tests, ablation and gates (final union at `0342e008b0`) - `pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2`: 45 files / **749 passed**, including 13 in the new `src/chunked-upload-integrity.test.ts`. - `pnpm --filter @objectstack/service-storage typecheck` (tsc, scripts and test layer): exit 0. - `pnpm --filter @objectstack/organizations exec vitest run` on the two wall files: **20 passed**. `pnpm --filter @objectstack/organizations typecheck`: exit 0 (at `b0b1ca0d83`). - `pnpm --filter @objectstack/dogfood exec vitest run --project isolated` on `test/storage-chunked-resume-integrity.dogfood.test.ts` and PR #22311's `test/storage-upload-limits.dogfood.test.ts`: **6 passed**. The new dogfood file compiles with 0 errors in `tsc --noEmit --listFiles`. The package-wide dogfood typecheck was NOT MEASURED locally: 84 TS2307 errors come from packages not built in this tree, none in this diff. - **Ablation** (`scripts/ablation-replace.mjs`, wrap mode; each leg's mutation and restore were proven by blob hash against HEAD). The unit suite imports `./storage-routes.js` from source, so no dist was involved. - A1, assemble the request's list instead of the record: the resumed-upload pin and 3 others go red. - A2, refusal disarmed: all 7 refusal pins go red. The first A2 leg was a no-op that the tool refused (its replacement was a substring of the anchor); it was re-run with a distinct replacement. - A2b, eTag check removed: only the mismatched-eTag pin goes red. - A2c, size check removed: the size and legacy-total pins go red. - A3, running total kept: the re-sent-progress pin goes red. - A4, append instead of replace: the re-sent-progress and replace-slot pins go red. - The dogfood file's red leg is its run on main above. - Gates at `0342e008b0`: `node scripts/pm/dispatch-gates.mjs --commands` derived 72 families (unchanged after the last merge). All 72 were run, plus `pnpm check:error-status-conformance` by hand, and every one exited 0. The error-status gate printed "every derivable runtime status is documented, and every documented status is reachable". `pnpm check:dispatcher-error-vocabulary` printed OK, `pnpm check:engine-double-contract` printed OK with 983 pinned, and `--ran` reconciled 72 of 72. (In the first run, before the last merge, `check:dual-build-cjs-loads` and `check:i18n` exited 3 with PREREQUISITE NOT MET because this tree was not fully built. Both were measured green in this run.) - Lint: `eslint --no-inline-config` on the 9 touched `.ts` files reported 9 files, 0 errors, 0 warnings (JSON count). `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. ## Acceptance notes - **Concurrent chunk PUTs lose a record entry (an existing defect, reported to the seat rather than fixed here).** The chunk door reads, modifies and writes `parts`. Two concurrent PUTs (chunks 0 and 1) both answered `200`, and the record kept only chunk 0. With this change, that upload's completion is refused `409` with `missingChunks` = `[1]`, so re-sending chunk 1 finishes it. On main, the same race corrupted the progress silently. No in-repo client uploads chunks in parallel: the SDK's `resumeUpload` is sequential, and objectui uses the presigned flow. - A resumed upload with a hole that is not a prefix (chunk 1 held, chunk 0 not) gets a `409` naming chunk 0 from the SDK, because `resumeUpload` resumes from the `uploadedChunks` count. The refusal's `details.missingChunks` is enough for a client to resume exactly; teaching the SDK to read it is a separate client change. - The chunk door still accepts a chunk index at or beyond `totalChunks`. The completion now refuses such an upload (`unexpectedChunks`), so no extra bytes reach an assembled file. - A `parts` value that is not a list of `{ chunkIndex, eTag }` is now refused `400 INVALID_REQUEST`. It used to throw a `500`. An absent `parts` is still an empty list. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28bff18 commit e36ee53

10 files changed

Lines changed: 885 additions & 23 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
---
4+
5+
fix(service-storage)!: a chunked upload completes with the file it was sent — the completion door assembles the parts the upload holds, and a re-sent chunk is counted once
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime upload door: a chunked completion whose upload does not hold its declared bytes, which used to answer 200 with a short or empty file, is refused with 409 before anything is assembled. The other categories are closed on facts: the one bumped package publishes (not unpublished); no ADR-0087 id covers these paths and this diff adds none (not registered / already-registered); and nothing exported is removed or narrowed — the diff changes no export, type or route option — so it is neither runtime-interface-only nor type-surface-only. -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
**What was wrong.** `POST /storage/upload/chunked/:uploadId/complete` assembled the parts the *request* listed. The SDK's `resumeUpload` lists only the chunks its own pass sent, so an upload whose first chunks were stored before an interruption completed with `200` and the declared `size`, while the stored file held only the resumed chunks. And `PUT …/chunk/:chunkIndex` added a re-sent chunk to the session's progress a second time, so `GET …/progress` overstated `uploadedSize`, `uploadedChunks` and `percentComplete` — the counts `resumeUpload` resumes from.
14+
15+
**What the completion door does now.** It assembles the upload's own record of the chunks it holds — every chunk the chunk door stored, with the eTag the backend answered for it — and checks the request's `parts` against that record. A resumed upload therefore completes with the whole file, whatever subset of parts the client lists, and the SDK's `resumeUpload` needs no change.
16+
17+
**What stops being accepted.** The door refuses with `409` and the standard code `RESOURCE_CONFLICT`, before anything is assembled, when:
18+
19+
- the upload does not hold every chunk it declared (chunk indexes `0` to `totalChunks - 1`), or holds one beyond them;
20+
- the chunks it holds do not add up to the declared `totalSize`;
21+
- the request lists a chunk the upload does not hold, or lists one with an eTag other than the one the upload holds for it.
22+
23+
`error.details` carries `missingChunks`, `unexpectedChunks`, `unheldListedChunks` and `mismatchedChunks` (zero-based chunk indexes), with `totalChunks`, `totalSize` and `heldBytes`. The session stays `in_progress` and the file stays `pending`: upload the missing chunks with `PUT …/chunk/:chunkIndex`, then complete again. A `parts` value that is not a list of `{ chunkIndex, eTag }` is refused `400 INVALID_REQUEST`; an absent `parts` is still an empty list.
24+
25+
**What changes for you.** A client that completed an upload before sending all of its bytes — including a completion with no chunks at all, which used to commit an empty file — now receives the `409` above. Send every chunk first.
26+
27+
**Progress.** A chunk index sent again replaces its slot, as it replaces its bytes in the backend: `uploadedChunks` counts distinct chunks and `uploadedSize` is the sum of each chunk's latest size. A session started before chunk sizes were recorded keeps its running byte total until it ends, and is completed only when that total reaches `totalSize`.

‎packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe('the upload doors on a booted organization wall (#22046)', () => {
109109
expect(res.status).toBe(200);
110110
return res.body.data.fileId;
111111
};
112-
const startChunked = async (): Promise<{ uploadId: string; fileId: string }> => {
112+
const startChunked = async (): Promise<{ uploadId: string; fileId: string; resumeToken: string }> => {
113113
const res = await answer(
114114
await stack.apiAs(uploader, 'POST', '/storage/upload/chunked', {
115115
filename: 'wall-chunked.bin',
@@ -118,7 +118,20 @@ describe('the upload doors on a booted organization wall (#22046)', () => {
118118
}),
119119
);
120120
expect(res.status).toBe(200);
121-
return { uploadId: res.body.data.uploadId, fileId: res.body.data.fileId };
121+
return { uploadId: res.body.data.uploadId, fileId: res.body.data.fileId, resumeToken: res.body.data.resumeToken };
122+
};
123+
/** The upload's declared 10 bytes, as chunk 0 — a completion assembles only an upload that holds them (#22313). */
124+
const holdTheDeclaredBytes = async (uploadId: string, resumeToken: string) => {
125+
const res = await stack.api(`/storage/upload/chunked/${uploadId}/chunk/0`, {
126+
method: 'PUT',
127+
headers: {
128+
Authorization: `Bearer ${uploader}`,
129+
'Content-Type': 'application/octet-stream',
130+
'x-resume-token': resumeToken,
131+
},
132+
body: new TextEncoder().encode('0123456789'),
133+
});
134+
expect(res.status, await res.clone().text()).toBe(200);
122135
};
123136

124137
/** The one refusal: code and status, and nothing about the row. */
@@ -262,7 +275,8 @@ describe('the upload doors on a booted organization wall (#22046)', () => {
262275
});
263276

264277
it('POSITIVE: the uploader completes', async () => {
265-
const { uploadId, fileId } = await startChunked();
278+
const { uploadId, fileId, resumeToken } = await startChunked();
279+
await holdTheDeclaredBytes(uploadId, resumeToken);
266280
const own = await answer(await stack.apiAs(uploader, 'POST', `/storage/upload/chunked/${uploadId}/complete`, { parts: [] }));
267281
expect(own.status).toBe(200);
268282
expect(own.body.data.fileId).toBe(fileId);

‎packages/plugins/organizations/src/storage-upload-organization-change.wall.test.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ describe('the upload doors after the uploader switches organization, on a booted
125125
});
126126
const progress = (uploadId: string) =>
127127
stack.apiAs(uploader, 'GET', `/storage/upload/chunked/${uploadId}/progress`);
128+
/**
129+
* The upload's declared 10 bytes, as chunk 0 — a completion assembles only
130+
* an upload that holds them (#22313). Sent from the organization the upload
131+
* was started in.
132+
*/
133+
const holdTheDeclaredBytes = async (uploadId: string, resumeToken: string) => {
134+
const res = await stack.api(`/storage/upload/chunked/${uploadId}/chunk/0`, {
135+
method: 'PUT',
136+
headers: {
137+
Authorization: `Bearer ${uploader}`,
138+
'Content-Type': 'application/octet-stream',
139+
'x-resume-token': resumeToken,
140+
},
141+
body: new TextEncoder().encode('0123456789'),
142+
});
143+
expect(res.status, await res.clone().text()).toBe(200);
144+
};
128145
/**
129146
* Moves a session's own `expires_at` into the past, as SYSTEM and in the
130147
* organization it was started in, so its expiry stamp is due. The row's
@@ -254,7 +271,8 @@ describe('the upload doors after the uploader switches organization, on a booted
254271

255272
it('ORGANIZATION CHANGED: 409 RESOURCE_CONFLICT naming the change, not 500; neither row moves; switching back completes it', async () => {
256273
await switchTo(startOrg);
257-
const { uploadId, fileId } = await startChunked();
274+
const { uploadId, fileId, resumeToken } = await startChunked();
275+
await holdTheDeclaredBytes(uploadId, resumeToken);
258276
const sessionBefore = await stored('sys_upload_session', uploadId);
259277
const fileBefore = await stored('sys_file', fileId);
260278

@@ -274,7 +292,8 @@ describe('the upload doors after the uploader switches organization, on a booted
274292

275293
it('CONTROL — SAME ORGANIZATION: the uploader completes exactly as before', async () => {
276294
await switchTo(startOrg);
277-
const { uploadId, fileId } = await startChunked();
295+
const { uploadId, fileId, resumeToken } = await startChunked();
296+
await holdTheDeclaredBytes(uploadId, resumeToken);
278297
const own = await answer(await complete(uploadId));
279298
expect(own.status, JSON.stringify(own.body)).toBe(200);
280299
expect(own.body.data.fileId).toBe(fileId);
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22313] A chunked upload completes with the file it was sent, or not at all —
4+
// over the HTTP doors of a REAL boot.
5+
//
6+
// The package suite (`service-storage/src/chunked-upload-integrity.test.ts`)
7+
// pins every rule at the route handlers. This file drives the four chunked
8+
// doors the way the SDK's `resumeUpload` drives them (`packages/client`):
9+
// progress first, then the chunks from `uploadedChunks` on, then a completion
10+
// listing only what that pass sent — and reads the stored object back through
11+
// the download door, because "200 with the declared size" is exactly what the
12+
// short file looked like.
13+
//
14+
// Not eligible for the shared showcase project: it boots its own storage root.
15+
16+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
17+
import { mkdtempSync } from 'node:fs';
18+
import { join } from 'node:path';
19+
import { tmpdir } from 'node:os';
20+
import { bootStack, type VerifyStack } from '@objectstack/verify';
21+
import { StorageServicePlugin } from '@objectstack/service-storage';
22+
import { attachmentsFixtureStack, attachmentsFixtureSecurity } from './fixtures/attachments-fixture.js';
23+
24+
const MIB = 1024 * 1024;
25+
/** The chunk size the init door floors every upload at. */
26+
const MIN_CHUNK = 5 * MIB;
27+
28+
describe('[#22313] a chunked upload completes with the file it was sent, over a real boot', () => {
29+
let stack: VerifyStack;
30+
let token: string;
31+
32+
const json = { 'Content-Type': 'application/json' };
33+
const auth = () => ({ Authorization: `Bearer ${token}` });
34+
35+
const init = async (totalSize: number) => {
36+
const res = await stack.api('/storage/upload/chunked', {
37+
method: 'POST',
38+
headers: { ...json, ...auth() },
39+
body: JSON.stringify({ filename: 'resume.bin', mimeType: 'application/octet-stream', totalSize }),
40+
});
41+
expect(res.status, await res.clone().text()).toBe(200);
42+
return ((await res.json()) as any).data as { uploadId: string; resumeToken: string; fileId: string; totalChunks: number };
43+
};
44+
45+
const putChunk = async (uploadId: string, resumeToken: string, chunkIndex: number, bytes: Uint8Array<ArrayBuffer>) => {
46+
const res = await stack.api(`/storage/upload/chunked/${uploadId}/chunk/${chunkIndex}`, {
47+
method: 'PUT',
48+
headers: { ...auth(), 'Content-Type': 'application/octet-stream', 'x-resume-token': resumeToken },
49+
body: bytes,
50+
});
51+
expect(res.status, await res.clone().text()).toBe(200);
52+
return ((await res.json()) as any).data.eTag as string;
53+
};
54+
55+
const progress = async (uploadId: string) => {
56+
const res = await stack.api(`/storage/upload/chunked/${uploadId}/progress`, { headers: auth() });
57+
expect(res.status, await res.clone().text()).toBe(200);
58+
return ((await res.json()) as any).data as Record<string, unknown>;
59+
};
60+
61+
const complete = (uploadId: string, parts: Array<{ chunkIndex: number; eTag: string }>) =>
62+
stack.api(`/storage/upload/chunked/${uploadId}/complete`, {
63+
method: 'POST',
64+
headers: { ...json, ...auth() },
65+
body: JSON.stringify({ uploadId, parts }),
66+
});
67+
68+
/** The stored object, read back through the public download door. */
69+
const download = async (fileId: string): Promise<Uint8Array> => {
70+
const res = await stack.api(`/storage/files/${fileId}/url`, { headers: auth() });
71+
expect(res.status, await res.clone().text()).toBe(200);
72+
const url = String(((await res.json()) as any).data.url).replace(/^https?:\/\/[^/]+/, '');
73+
const bytes = await stack.raw(url);
74+
expect(bytes.status).toBe(200);
75+
return new Uint8Array(await bytes.arrayBuffer());
76+
};
77+
78+
const fill = (byte: number, n: number) => new Uint8Array(n).fill(byte);
79+
const concat = (a: Uint8Array, b: Uint8Array) => {
80+
const out = new Uint8Array(a.length + b.length);
81+
out.set(a, 0);
82+
out.set(b, a.length);
83+
return out;
84+
};
85+
86+
beforeAll(async () => {
87+
const rootDir = mkdtempSync(join(tmpdir(), 'chunked-resume-dogfood-'));
88+
stack = await bootStack(attachmentsFixtureStack as never, {
89+
security: attachmentsFixtureSecurity(),
90+
// `bindToSettings:false` keeps the constructor rootDir; the size limit is
91+
// not what this file is about.
92+
extraPlugins: [new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false })],
93+
});
94+
token = await stack.signIn();
95+
}, 120_000);
96+
97+
afterAll(async () => {
98+
await stack?.stop();
99+
});
100+
101+
it('a RESUMED upload, driven as resumeUpload drives it, completes with the FULL file', async () => {
102+
const chunk0 = fill(0x61, MIN_CHUNK);
103+
const chunk1 = fill(0x62, 10);
104+
const { uploadId, resumeToken, fileId, totalChunks } = await init(MIN_CHUNK + 10);
105+
expect(totalChunks).toBe(2);
106+
107+
// First pass: chunk 0 lands, then the client goes away.
108+
await putChunk(uploadId, resumeToken, 0, chunk0);
109+
110+
// The resume: progress, the chunks from `uploadedChunks` on, and a
111+
// completion listing only the part this pass sent.
112+
const before = await progress(uploadId);
113+
expect(before.uploadedChunks).toBe(1);
114+
const eTag1 = await putChunk(uploadId, resumeToken, 1, chunk1);
115+
const res = await complete(uploadId, [{ chunkIndex: 1, eTag: eTag1 }]);
116+
117+
expect(res.status, await res.clone().text()).toBe(200);
118+
expect(((await res.json()) as any).data.size).toBe(MIN_CHUNK + 10);
119+
const stored = await download(fileId);
120+
expect(stored.length, 'the stored object is the whole file, not the resumed chunk alone').toBe(MIN_CHUNK + 10);
121+
expect(Buffer.from(stored).equals(Buffer.from(concat(chunk0, chunk1)))).toBe(true);
122+
});
123+
124+
it('REFUSED: an upload that does not hold a declared chunk is refused 409 RESOURCE_CONFLICT naming it', async () => {
125+
const { uploadId, resumeToken } = await init(MIN_CHUNK + 10);
126+
const eTag1 = await putChunk(uploadId, resumeToken, 1, fill(0x62, 10));
127+
128+
const res = await complete(uploadId, [{ chunkIndex: 1, eTag: eTag1 }]);
129+
130+
expect(res.status, await res.clone().text()).toBe(409);
131+
const body = (await res.json()) as any;
132+
expect(body.success).toBe(false);
133+
expect(body.error.code).toBe('RESOURCE_CONFLICT');
134+
expect(body.error.details.missingChunks).toEqual([0]);
135+
// Still resumable: nothing was assembled and the session is in flight.
136+
expect((await progress(uploadId)).status).toBe('in_progress');
137+
});
138+
139+
it('a RE-SENT chunk leaves the progress unchanged', async () => {
140+
const { uploadId, resumeToken } = await init(MIB);
141+
const chunk = fill(0x61, 600 * 1024);
142+
143+
await putChunk(uploadId, resumeToken, 0, chunk);
144+
const once = await progress(uploadId);
145+
await putChunk(uploadId, resumeToken, 0, chunk);
146+
const twice = await progress(uploadId);
147+
148+
expect(once).toMatchObject({ uploadedSize: 600 * 1024, uploadedChunks: 1, percentComplete: 59 });
149+
expect({ uploadedSize: twice.uploadedSize, uploadedChunks: twice.uploadedChunks, percentComplete: twice.percentComplete })
150+
.toEqual({ uploadedSize: once.uploadedSize, uploadedChunks: once.uploadedChunks, percentComplete: once.percentComplete });
151+
});
152+
153+
it('CONTROL: a clean one-pass upload listing every part is unchanged', async () => {
154+
const chunk0 = fill(0x61, MIN_CHUNK);
155+
const chunk1 = fill(0x62, 10);
156+
const { uploadId, resumeToken, fileId } = await init(MIN_CHUNK + 10);
157+
const eTag0 = await putChunk(uploadId, resumeToken, 0, chunk0);
158+
const eTag1 = await putChunk(uploadId, resumeToken, 1, chunk1);
159+
160+
const res = await complete(uploadId, [
161+
{ chunkIndex: 0, eTag: eTag0 },
162+
{ chunkIndex: 1, eTag: eTag1 },
163+
]);
164+
165+
expect(res.status, await res.clone().text()).toBe(200);
166+
expect(((await res.json()) as any).data).toMatchObject({ fileId, size: MIN_CHUNK + 10 });
167+
expect(Buffer.from(await download(fileId)).equals(Buffer.from(concat(chunk0, chunk1)))).toBe(true);
168+
});
169+
});

0 commit comments

Comments
 (0)