Skip to content

Commit 41a5524

Browse files
yoavniranUploady CI
andauthored
feat: new Tus Event + Hook - Part Start (#910)
closes #907 * feat: tus-sender - new event: PART_START * feat: tus-uploady - new event hook: PartStart * test: e2e tus upload multiple files/parts headers * chore: update e2e weights file --------- Co-authored-by: Uploady CI <ci@react-uploady.org>
1 parent 22d69d0 commit 41a5524

24 files changed

+1362
-581
lines changed

cypress/e2e-weights.json

Lines changed: 219 additions & 213 deletions
Large diffs are not rendered by default.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import uploadFile from "../uploadFile";
2+
import { createTusIntercepts, uploadUrl } from "./tusIntercept";
3+
import clearTusPersistStorage from "./clearTusPersistStorage";
4+
5+
describe("TusUploady - Multiple Files Same Headers", () => {
6+
const fileName = "flower.jpg";
7+
const chunkSize = 122_445;//172445;
8+
9+
const setupEventHandlers = (withPartStart = false) => {
10+
let bearerToken = 0;
11+
12+
cy.setUploadOptions({
13+
preSendData: ({ options, items }) => {
14+
bearerToken += 1;
15+
return {
16+
options: {
17+
params: { [`TestParam-${items[0].id}`]: items[0].file.name },
18+
destination: {
19+
headers: { "Authorization": "bearer " + bearerToken },
20+
}
21+
}
22+
}
23+
},
24+
partStartData: withPartStart ? (data) => {
25+
const { chunk } = data;
26+
const authHeaderVal = data.headers["Authorization"];
27+
28+
return {
29+
headers: {
30+
"Authorization": authHeaderVal + "-part-" + (chunk.index + 1),
31+
}
32+
};
33+
} : undefined
34+
});
35+
};
36+
37+
beforeEach(() => {
38+
clearTusPersistStorage();
39+
40+
cy.visitStory(
41+
"tusUploady",
42+
"simple",
43+
{
44+
uploadUrl,
45+
chunkSize,
46+
forgetOnSuccess: true,
47+
tusSendOnCreate: true,
48+
tusResumeStorage: true,
49+
tusIgnoreModifiedDateInStorage: true,
50+
tusSendWithCustomHeader: true,
51+
}
52+
);
53+
});
54+
55+
it.skip("should upload multiple files in a single batch with unique header/param for upload request", () => {
56+
const numberOfFiles = 3;
57+
58+
setupEventHandlers();
59+
60+
const {
61+
assertCreateRequest,
62+
assertPatchRequest,
63+
assertCreateRequestByIndex,
64+
assertCreateRequestTimes,
65+
assertLastCreateRequest,
66+
} = createTusIntercepts({ batchSize: numberOfFiles, chunkSize, });
67+
68+
cy.get("input")
69+
.should("exist")
70+
.as("fInput");
71+
72+
// Upload multiple files in a single batch
73+
uploadFile(fileName, () => {
74+
cy.waitShort();
75+
76+
// Verify all files started and finished
77+
cy.storyLog().assertFileItemStartFinish(fileName, 1);
78+
79+
// Verify we got the expected number of CREATE requests (one per file)
80+
assertCreateRequestTimes(numberOfFiles, `Should have ${numberOfFiles} CREATE requests for ${numberOfFiles} files`);
81+
82+
// Use the working x-test header to verify header consistency across files
83+
// Assert first CREATE request has the Authorization header as requested
84+
assertCreateRequest(0, ({ request }) => {
85+
expect(request.headers["authorization"]).to.eq("bearer 1", "First file CREATE request should have Authorization header");
86+
expect(request.headers["x-test"]).to.eq("abcd", "Should also have the predefined x-test header");
87+
expect(request.headers["upload-metadata"]).to.contain("TestParam-batch-1.item-1", "Should have unique TestParam metadata for the first file");
88+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
89+
// With sendDataOnCreate, content-length should be > 0
90+
if (request.headers["content-length"]) {
91+
expect(parseInt(request.headers["content-length"])).to.be.greaterThan(0);
92+
}
93+
});
94+
95+
// Assert last CREATE request also has the same Authorization header to verify consistency across multiple files
96+
assertCreateRequestByIndex(1, ({ request }) => {
97+
expect(request.headers["authorization"]).to.eq("bearer 2", "Second file CREATE request should have same Authorization header");
98+
expect(request.headers["upload-metadata"]).to.contain("TestParam-batch-1.item-2", "Should have unique TestParam metadata for the second file");
99+
expect(request.headers["x-test"]).to.eq("abcd", "Should also have the predefined x-test header");
100+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
101+
});
102+
103+
cy.waitExtraShort();
104+
105+
assertLastCreateRequest(({ request }) => {
106+
expect(request.headers["authorization"]).to.eq("bearer 3", "Last file CREATE request should have same Authorization header");
107+
expect(request.headers["upload-metadata"]).to.contain("TestParam-batch-1.item-3", "Should have unique TestParam metadata for the third file");
108+
expect(request.headers["x-test"]).to.eq("abcd", "Should also have the predefined x-test header");
109+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
110+
});
111+
112+
// Assert PATCH requests have consistent authorization header from resumeHeaders
113+
assertPatchRequest(chunkSize, 0, ({ request }) => {
114+
expect(request.headers["authorization"]).to.eq("bearer 1", "PATCH request should have Authorization header from resumeHeaders");
115+
expect(request.headers["content-type"]).to.eq("application/offset+octet-stream");
116+
expect(request.headers["upload-offset"]).to.be.a("string");
117+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
118+
});
119+
120+
assertPatchRequest(chunkSize, 1, ({ request }) => {
121+
expect(request.headers["authorization"]).to.eq("bearer 2", "PATCH request should have Authorization header from resumeHeaders");
122+
expect(request.headers["content-type"]).to.eq("application/offset+octet-stream");
123+
expect(request.headers["upload-offset"]).to.be.a("string");
124+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
125+
});
126+
127+
assertPatchRequest(chunkSize, 2, ({ request }) => {
128+
expect(request.headers["authorization"]).to.eq("bearer 3", "PATCH request should have Authorization header from resumeHeaders");
129+
expect(request.headers["upload-offset"]).to.be.a("string");
130+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
131+
});
132+
133+
}, "#upload-button", { times: numberOfFiles });
134+
});
135+
136+
it("should upload multiple files with multiple parts each with their own unique header", () => {
137+
const numberOfFiles = 2;
138+
139+
setupEventHandlers(true);
140+
141+
const {
142+
assertCreateRequest,
143+
assertPatchRequest,
144+
assertCreateRequestByIndex,
145+
assertCreateRequestTimes,
146+
assertLastCreateRequest,
147+
} = createTusIntercepts({ batchSize: numberOfFiles });
148+
149+
cy.get("input")
150+
.should("exist")
151+
.as("fInput");
152+
153+
// Upload multiple files in a single batch
154+
uploadFile(fileName, () => {
155+
cy.waitShort();
156+
157+
// Verify all files started and finished
158+
cy.storyLog().assertFileItemStartFinish(fileName, 1);
159+
160+
// Verify we got the expected number of CREATE requests (one per file)
161+
assertCreateRequestTimes(numberOfFiles, `Should have ${numberOfFiles} CREATE requests for ${numberOfFiles} files`);
162+
163+
assertCreateRequest(0, ({ request }) => {
164+
expect(request.headers["authorization"]).to.eq("bearer 1", "First file CREATE request should have Authorization header");
165+
expect(request.headers["x-test"]).to.eq("abcd", "Should also have the predefined x-test header");
166+
expect(request.headers["upload-metadata"]).to.contain("TestParam-batch-1.item-1", "Should have unique TestParam metadata for the first file");
167+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
168+
// With sendDataOnCreate, content-length should be > 0
169+
if (request.headers["content-length"]) {
170+
expect(parseInt(request.headers["content-length"])).to.be.greaterThan(0);
171+
}
172+
});
173+
174+
assertPatchRequest(chunkSize, 0, ({ request }) => {
175+
expect(request.headers["authorization"]).to.eq("bearer 1-part-1", "PATCH request should have unique Authorization header from part start handler");
176+
expect(request.headers["upload-offset"]).to.be.a("string");
177+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
178+
});
179+
180+
assertPatchRequest(chunkSize, 0, ({ request }) => {
181+
expect(request.headers["authorization"]).to.eq("bearer 1-part-2", "PATCH request should have unique Authorization header from part start handler");
182+
});
183+
184+
//dont use chunk size for last part as it will be smaller
185+
assertPatchRequest(0, 0, ({ request }) => {
186+
expect(request.headers["authorization"]).to.eq("bearer 1-part-3", "PATCH request should have unique Authorization header from part start handler");
187+
});
188+
189+
assertCreateRequestByIndex(1, ({ request }) => {
190+
expect(request.headers["authorization"]).to.eq("bearer 2", "First file CREATE request should have Authorization header");
191+
expect(request.headers["x-test"]).to.eq("abcd", "Should also have the predefined x-test header");
192+
expect(request.headers["upload-metadata"]).to.contain("TestParam-batch-1.item-2", "Should have unique TestParam metadata for the first file");
193+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
194+
// With sendDataOnCreate, content-length should be > 0
195+
if (request.headers["content-length"]) {
196+
expect(parseInt(request.headers["content-length"])).to.be.greaterThan(0);
197+
}
198+
});
199+
200+
assertPatchRequest(chunkSize, 1, ({ request }) => {
201+
expect(request.headers["authorization"]).to.eq("bearer 2-part-1", "PATCH request should have unique Authorization header from part start handler");
202+
expect(request.headers["upload-offset"]).to.be.a("string");
203+
expect(request.headers["tus-resumable"]).to.eq("1.0.0");
204+
});
205+
206+
assertPatchRequest(chunkSize, 1, ({ request }) => {
207+
expect(request.headers["authorization"]).to.eq("bearer 2-part-2", "PATCH request should have unique Authorization header from part start handler");
208+
});
209+
210+
//dont use chunk size for last part as it will be smaller
211+
assertPatchRequest(0, 1, ({ request }) => {
212+
expect(request.headers["authorization"]).to.eq("bearer 2-part-3", "PATCH request should have unique Authorization header from part start handler");
213+
});
214+
215+
}, "#upload-button", { times: numberOfFiles });
216+
});
217+
});

cypress/integration/tus-uploady/tusIntercept.js

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,33 @@ beforeEach(() => {
1313
export const createTusIntercepts = (tusOptions = {}) => {
1414
callCount += 1;
1515

16+
const addPatchIntercept = (delay, alias, url, tusData, index) => {
17+
interceptWithDelay(delay, alias, url, "PATCH",
18+
(req) => {
19+
const offset = parseInt(req.headers["content-length"]);
20+
tusData[index].offset += offset;
21+
22+
return {
23+
statusCode: 204,
24+
body: "",
25+
headers: {
26+
"Tus-Resumable": "1.0.0",
27+
"Upload-Offset": `${tusData[index].offset}`,
28+
}
29+
};
30+
});
31+
}
32+
1633
const options = {
1734
parallel: 1,
35+
batchSize: 1,
1836
deferLength: false,
1937
patchDelay: 0,
2038
id: String.fromCharCode(callCount),
2139
...tusOptions
2240
};
2341

24-
const tusData = Array.from({ length: options.parallel },
42+
const tusData = Array.from({ length: Math.max(options.parallel, options.batchSize) },
2543
(_, i) => i).map((i) => {
2644
const path = i + 1
2745
return {
@@ -73,21 +91,9 @@ export const createTusIntercepts = (tusOptions = {}) => {
7391
}
7492
}).as(getA("tusCreateReq"));
7593

94+
// Create PATCH intercepts for initial tusData entries
7695
tusData.forEach(({ path }, i) => {
77-
interceptWithDelay(options.patchDelay, getA(`tusPatchReq${i + 1}`), `${uploadUrl}/${path}`, "PATCH",
78-
(req) => {
79-
const offset = parseInt(req.headers["content-length"]);
80-
tusData[i].offset += offset;
81-
82-
return {
83-
statusCode: 204,
84-
body: "",
85-
headers: {
86-
"Tus-Resumable": "1.0.0",
87-
"Upload-Offset": `${tusData[i].offset}`,
88-
}
89-
};
90-
});
96+
addPatchIntercept(options.patchDelay, getA(`tusPatchReq${i + 1}`), `${uploadUrl}/${path}`, tusData, i);
9197
});
9298

9399
const getPartUrls = () =>
@@ -121,9 +127,12 @@ export const createTusIntercepts = (tusOptions = {}) => {
121127
cy.wait(getA(`tusPatchReq${index + 1}`, true))
122128
.then((xhr) => {
123129
const { headers } = xhr.request;
124-
expect(headers["content-length"]).to.eq(`${contentLength}`);
125130
expect(headers["content-type"]).to.eq("application/offset+octet-stream");
126131

132+
if (contentLength) {
133+
expect(headers["content-length"]).to.eq(`${contentLength}`);
134+
}
135+
127136
cb?.(xhr);
128137
});
129138
},
@@ -152,6 +161,14 @@ export const createTusIntercepts = (tusOptions = {}) => {
152161
});
153162
},
154163

164+
assertCreateRequestByIndex: (index, cb = null) => {
165+
cy.get(getA("tusCreateReq", true, true))
166+
.then((calls) => {
167+
const xhr = calls[index];
168+
cb(xhr);
169+
});
170+
},
171+
155172
assertLastCreateRequest: (cb) => {
156173
cy.get(getA("tusCreateReq", true, true))
157174
.then((calls) => {
@@ -215,4 +232,3 @@ export const createTusIntercepts = (tusOptions = {}) => {
215232
},
216233
};
217234
};
218-

packages/core/shared/src/tests/mocks/rpldy-shared.mock.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ utils.isPromise = vi.fn((...args) => isPromise(...args));
4040
//keep scheduleIdleWork working
4141
utils.scheduleIdleWork = vi.fn((fn) => fn());
4242

43+
utils.isPlainObject = vi.fn(() => false);
44+
4345
const sharedMock = {
4446
FILE_STATES: ORG_FILES_STATES,
4547
BATCH_STATES: ORG_BATCH_STATES,

packages/core/tus-sender/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,11 @@ Triggered before the (HEAD) request is issued on behalf of a potentially resumea
147147
148148
> This event is _[cancellable](https://react-uploady.org/docs/api/events/#cancellable-events)_
149149
150-
The event handler receives a [ResumeStartEventData](https://react-uploady.org/docs/api/types/#resumestarteventdata) object
150+
The event handler receives a [ResumeStartEventData](https://react-uploady.org/docs/api/types/#resumestarteventdata) object
151+
152+
### TUS_EVENTS.PART_START
153+
154+
Triggered before a (PATCH) request is issued to upload a chunk (part) of a file.
155+
156+
The event handler receives a [PartStartEventData](https://react-uploady.org/docs/api/types/#tuspartstarteventresponse) object
157+

packages/core/tus-sender/src/consts.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ export const TUS_SENDER_TYPE = "rpldy-tus-sender";
88
export const SUCCESS_CODES = [200, 201, 204];
99

1010
export const KNOWN_EXTENSIONS = {
11-
CREATION: "creation",
12-
CREATION_WITH_UPLOAD: "creation-with-upload",
13-
TERMINATION: "termination",
14-
CONCATENATION: "concatenation",
15-
CREATION_DEFER_LENGTH: "creation-defer-length",
11+
CREATION: "creation",
12+
CREATION_WITH_UPLOAD: "creation-with-upload",
13+
TERMINATION: "termination",
14+
CONCATENATION: "concatenation",
15+
CREATION_DEFER_LENGTH: "creation-defer-length",
1616
};
1717

1818
export const FD_STORAGE_PREFIX = "rpldy_tus_fd_";
1919

2020
export const TUS_EVENTS: Object = devFreeze({
21-
RESUME_START: "RESUME_START"
21+
RESUME_START: "RESUME_START",
22+
PART_START: "PART_START"
2223
});

packages/core/tus-sender/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,6 @@ export type {
2525
TusOptions,
2626
ResumeStartEventData,
2727
ResumeStartEventResponse,
28+
PartStartEventData,
29+
PartStartResponseData
2830
} from "./types";

0 commit comments

Comments
 (0)