@durable-streams/server-conformance-tests 0.1.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 +132 -0
- package/bin/conformance-dev.mjs +27 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +221 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +3 -0
- package/dist/src-DRIMnUPk.js +2326 -0
- package/dist/test-runner.d.ts +1 -0
- package/dist/test-runner.js +8 -0
- package/package.json +43 -0
- package/src/cli.ts +345 -0
- package/src/index.ts +3596 -0
- package/src/test-runner.ts +19 -0
|
@@ -0,0 +1,2326 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { DurableStream, STREAM_OFFSET_HEADER, STREAM_SEQ_HEADER, STREAM_UP_TO_DATE_HEADER } from "@durable-streams/client";
|
|
4
|
+
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
/**
|
|
7
|
+
* Helper to fetch SSE stream and read until a condition is met.
|
|
8
|
+
* Handles AbortController, timeout, and cleanup automatically.
|
|
9
|
+
*/
|
|
10
|
+
async function fetchSSE(url, opts = {}) {
|
|
11
|
+
const { timeoutMs = 2e3, maxChunks = 10, untilContent, headers = {}, signal } = opts;
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
14
|
+
if (signal) signal.addEventListener(`abort`, () => controller.abort());
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(url, {
|
|
17
|
+
method: `GET`,
|
|
18
|
+
headers,
|
|
19
|
+
signal: controller.signal
|
|
20
|
+
});
|
|
21
|
+
if (!response.body) {
|
|
22
|
+
clearTimeout(timeoutId);
|
|
23
|
+
return {
|
|
24
|
+
response,
|
|
25
|
+
received: ``
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const reader = response.body.getReader();
|
|
29
|
+
const decoder = new TextDecoder();
|
|
30
|
+
let received = ``;
|
|
31
|
+
let untilContentIndex = -1;
|
|
32
|
+
for (let i = 0; i < maxChunks; i++) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done) break;
|
|
35
|
+
received += decoder.decode(value, { stream: true });
|
|
36
|
+
if (untilContent && received.includes(untilContent) && untilContentIndex < 0) untilContentIndex = received.indexOf(untilContent);
|
|
37
|
+
const normalized = received.replace(/\r\n/g, `\n`);
|
|
38
|
+
if (untilContentIndex >= 0 && normalized.lastIndexOf(`\n\n`) > untilContentIndex) break;
|
|
39
|
+
}
|
|
40
|
+
clearTimeout(timeoutId);
|
|
41
|
+
reader.cancel();
|
|
42
|
+
return {
|
|
43
|
+
response,
|
|
44
|
+
received
|
|
45
|
+
};
|
|
46
|
+
} catch (e) {
|
|
47
|
+
clearTimeout(timeoutId);
|
|
48
|
+
if (e instanceof Error && e.name === `AbortError`) return {
|
|
49
|
+
response: new Response(),
|
|
50
|
+
received: ``
|
|
51
|
+
};
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse SSE events from raw SSE text.
|
|
57
|
+
* Handles multi-line data correctly by joining data: lines per the SSE spec.
|
|
58
|
+
* Returns an array of parsed events with type and data.
|
|
59
|
+
*/
|
|
60
|
+
function parseSSEEvents(sseText) {
|
|
61
|
+
const events = [];
|
|
62
|
+
const normalized = sseText.replace(/\r\n/g, `\n`);
|
|
63
|
+
const eventBlocks = normalized.split(`\n\n`).filter((block) => block.trim());
|
|
64
|
+
for (const block of eventBlocks) {
|
|
65
|
+
const lines = block.split(`\n`);
|
|
66
|
+
let eventType = ``;
|
|
67
|
+
const dataLines = [];
|
|
68
|
+
for (const line of lines) if (line.startsWith(`event:`)) eventType = line.slice(6).trim();
|
|
69
|
+
else if (line.startsWith(`data:`)) {
|
|
70
|
+
const content = line.slice(5);
|
|
71
|
+
dataLines.push(content.startsWith(` `) ? content.slice(1) : content);
|
|
72
|
+
}
|
|
73
|
+
if (eventType && dataLines.length > 0) events.push({
|
|
74
|
+
type: eventType,
|
|
75
|
+
data: dataLines.join(`\n`)
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return events;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Run the full conformance test suite against a server
|
|
82
|
+
*/
|
|
83
|
+
function runConformanceTests(options) {
|
|
84
|
+
const getBaseUrl = () => options.baseUrl;
|
|
85
|
+
describe(`Basic Stream Operations`, () => {
|
|
86
|
+
test(`should create a stream`, async () => {
|
|
87
|
+
const streamPath = `/v1/stream/create-test-${Date.now()}`;
|
|
88
|
+
const stream = await DurableStream.create({
|
|
89
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
90
|
+
contentType: `text/plain`
|
|
91
|
+
});
|
|
92
|
+
expect(stream.url).toBe(`${getBaseUrl()}${streamPath}`);
|
|
93
|
+
});
|
|
94
|
+
test(`should allow idempotent create with same config`, async () => {
|
|
95
|
+
const streamPath = `/v1/stream/duplicate-test-${Date.now()}`;
|
|
96
|
+
await DurableStream.create({
|
|
97
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
98
|
+
contentType: `text/plain`
|
|
99
|
+
});
|
|
100
|
+
await DurableStream.create({
|
|
101
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
102
|
+
contentType: `text/plain`
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
test(`should reject create with different config (409)`, async () => {
|
|
106
|
+
const streamPath = `/v1/stream/config-mismatch-test-${Date.now()}`;
|
|
107
|
+
await DurableStream.create({
|
|
108
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
109
|
+
contentType: `text/plain`
|
|
110
|
+
});
|
|
111
|
+
await expect(DurableStream.create({
|
|
112
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
113
|
+
contentType: `application/json`
|
|
114
|
+
})).rejects.toThrow();
|
|
115
|
+
});
|
|
116
|
+
test(`should delete a stream`, async () => {
|
|
117
|
+
const streamPath = `/v1/stream/delete-test-${Date.now()}`;
|
|
118
|
+
const stream = await DurableStream.create({
|
|
119
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
120
|
+
contentType: `text/plain`
|
|
121
|
+
});
|
|
122
|
+
await stream.delete();
|
|
123
|
+
await expect(stream.stream({ live: false })).rejects.toThrow();
|
|
124
|
+
});
|
|
125
|
+
test(`should properly isolate recreated stream after delete`, async () => {
|
|
126
|
+
const streamPath = `/v1/stream/delete-recreate-test-${Date.now()}`;
|
|
127
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
128
|
+
method: `PUT`,
|
|
129
|
+
headers: { "Content-Type": `text/plain` },
|
|
130
|
+
body: `old data`
|
|
131
|
+
});
|
|
132
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
133
|
+
method: `POST`,
|
|
134
|
+
headers: { "Content-Type": `text/plain` },
|
|
135
|
+
body: ` more old data`
|
|
136
|
+
});
|
|
137
|
+
const readOld = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
138
|
+
const oldText = await readOld.text();
|
|
139
|
+
expect(oldText).toBe(`old data more old data`);
|
|
140
|
+
const deleteResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `DELETE` });
|
|
141
|
+
expect(deleteResponse.status).toBe(204);
|
|
142
|
+
const recreateResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
143
|
+
method: `PUT`,
|
|
144
|
+
headers: { "Content-Type": `text/plain` },
|
|
145
|
+
body: `new data`
|
|
146
|
+
});
|
|
147
|
+
expect(recreateResponse.status).toBe(201);
|
|
148
|
+
const readNew = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
149
|
+
const newText = await readNew.text();
|
|
150
|
+
expect(newText).toBe(`new data`);
|
|
151
|
+
expect(newText).not.toContain(`old data`);
|
|
152
|
+
expect(readNew.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
|
|
153
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
154
|
+
method: `POST`,
|
|
155
|
+
headers: { "Content-Type": `text/plain` },
|
|
156
|
+
body: ` appended`
|
|
157
|
+
});
|
|
158
|
+
const finalRead = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
159
|
+
const finalText = await finalRead.text();
|
|
160
|
+
expect(finalText).toBe(`new data appended`);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
describe(`Append Operations`, () => {
|
|
164
|
+
test(`should append string data`, async () => {
|
|
165
|
+
const streamPath = `/v1/stream/append-test-${Date.now()}`;
|
|
166
|
+
const stream = await DurableStream.create({
|
|
167
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
168
|
+
contentType: `text/plain`
|
|
169
|
+
});
|
|
170
|
+
await stream.append(`hello world`);
|
|
171
|
+
const res = await stream.stream({ live: false });
|
|
172
|
+
const text = await res.text();
|
|
173
|
+
expect(text).toBe(`hello world`);
|
|
174
|
+
});
|
|
175
|
+
test(`should append multiple chunks`, async () => {
|
|
176
|
+
const streamPath = `/v1/stream/multi-append-test-${Date.now()}`;
|
|
177
|
+
const stream = await DurableStream.create({
|
|
178
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
179
|
+
contentType: `text/plain`
|
|
180
|
+
});
|
|
181
|
+
await stream.append(`chunk1`);
|
|
182
|
+
await stream.append(`chunk2`);
|
|
183
|
+
await stream.append(`chunk3`);
|
|
184
|
+
const res = await stream.stream({ live: false });
|
|
185
|
+
const text = await res.text();
|
|
186
|
+
expect(text).toBe(`chunk1chunk2chunk3`);
|
|
187
|
+
});
|
|
188
|
+
test(`should enforce sequence ordering with seq`, async () => {
|
|
189
|
+
const streamPath = `/v1/stream/seq-test-${Date.now()}`;
|
|
190
|
+
const stream = await DurableStream.create({
|
|
191
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
192
|
+
contentType: `text/plain`
|
|
193
|
+
});
|
|
194
|
+
await stream.append(`first`, { seq: `001` });
|
|
195
|
+
await stream.append(`second`, { seq: `002` });
|
|
196
|
+
await expect(stream.append(`invalid`, { seq: `001` })).rejects.toThrow();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe(`Read Operations`, () => {
|
|
200
|
+
test(`should read empty stream`, async () => {
|
|
201
|
+
const streamPath = `/v1/stream/read-empty-test-${Date.now()}`;
|
|
202
|
+
const stream = await DurableStream.create({
|
|
203
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
204
|
+
contentType: `text/plain`
|
|
205
|
+
});
|
|
206
|
+
const res = await stream.stream({ live: false });
|
|
207
|
+
const body = await res.body();
|
|
208
|
+
expect(body.length).toBe(0);
|
|
209
|
+
expect(res.upToDate).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
test(`should read stream with data`, async () => {
|
|
212
|
+
const streamPath = `/v1/stream/read-data-test-${Date.now()}`;
|
|
213
|
+
const stream = await DurableStream.create({
|
|
214
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
215
|
+
contentType: `text/plain`
|
|
216
|
+
});
|
|
217
|
+
await stream.append(`hello`);
|
|
218
|
+
const res = await stream.stream({ live: false });
|
|
219
|
+
const text = await res.text();
|
|
220
|
+
expect(text).toBe(`hello`);
|
|
221
|
+
expect(res.upToDate).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
test(`should read from offset`, async () => {
|
|
224
|
+
const streamPath = `/v1/stream/read-offset-test-${Date.now()}`;
|
|
225
|
+
const stream = await DurableStream.create({
|
|
226
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
227
|
+
contentType: `text/plain`
|
|
228
|
+
});
|
|
229
|
+
await stream.append(`first`);
|
|
230
|
+
const res1 = await stream.stream({ live: false });
|
|
231
|
+
await res1.text();
|
|
232
|
+
const firstOffset = res1.offset;
|
|
233
|
+
await stream.append(`second`);
|
|
234
|
+
const res2 = await stream.stream({
|
|
235
|
+
offset: firstOffset,
|
|
236
|
+
live: false
|
|
237
|
+
});
|
|
238
|
+
const text = await res2.text();
|
|
239
|
+
expect(text).toBe(`second`);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
describe(`Long-Poll Operations`, () => {
|
|
243
|
+
test(`should wait for new data with long-poll`, async () => {
|
|
244
|
+
const streamPath = `/v1/stream/longpoll-test-${Date.now()}`;
|
|
245
|
+
const stream = await DurableStream.create({
|
|
246
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
247
|
+
contentType: `text/plain`
|
|
248
|
+
});
|
|
249
|
+
const receivedData = [];
|
|
250
|
+
const readPromise = (async () => {
|
|
251
|
+
const res = await stream.stream({ live: `long-poll` });
|
|
252
|
+
await new Promise((resolve) => {
|
|
253
|
+
const unsubscribe = res.subscribeBytes(async (chunk) => {
|
|
254
|
+
if (chunk.data.length > 0) receivedData.push(new TextDecoder().decode(chunk.data));
|
|
255
|
+
if (receivedData.length >= 1) {
|
|
256
|
+
unsubscribe();
|
|
257
|
+
res.cancel();
|
|
258
|
+
resolve();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
})();
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
264
|
+
await stream.append(`new data`);
|
|
265
|
+
await readPromise;
|
|
266
|
+
expect(receivedData).toContain(`new data`);
|
|
267
|
+
}, 1e4);
|
|
268
|
+
test(`should return immediately if data already exists`, async () => {
|
|
269
|
+
const streamPath = `/v1/stream/longpoll-immediate-test-${Date.now()}`;
|
|
270
|
+
const stream = await DurableStream.create({
|
|
271
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
272
|
+
contentType: `text/plain`
|
|
273
|
+
});
|
|
274
|
+
await stream.append(`existing data`);
|
|
275
|
+
const res = await stream.stream({ live: false });
|
|
276
|
+
const text = await res.text();
|
|
277
|
+
expect(text).toBe(`existing data`);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
describe(`HTTP Protocol`, () => {
|
|
281
|
+
test(`should return correct headers on PUT`, async () => {
|
|
282
|
+
const streamPath = `/v1/stream/put-headers-test-${Date.now()}`;
|
|
283
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
284
|
+
method: `PUT`,
|
|
285
|
+
headers: { "Content-Type": `text/plain` }
|
|
286
|
+
});
|
|
287
|
+
expect(response.status).toBe(201);
|
|
288
|
+
expect(response.headers.get(`content-type`)).toBe(`text/plain`);
|
|
289
|
+
expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
test(`should return 200 on idempotent PUT with same config`, async () => {
|
|
292
|
+
const streamPath = `/v1/stream/duplicate-put-test-${Date.now()}`;
|
|
293
|
+
const firstResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
294
|
+
method: `PUT`,
|
|
295
|
+
headers: { "Content-Type": `text/plain` }
|
|
296
|
+
});
|
|
297
|
+
expect(firstResponse.status).toBe(201);
|
|
298
|
+
const secondResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
299
|
+
method: `PUT`,
|
|
300
|
+
headers: { "Content-Type": `text/plain` }
|
|
301
|
+
});
|
|
302
|
+
expect([200, 204]).toContain(secondResponse.status);
|
|
303
|
+
});
|
|
304
|
+
test(`should return 409 on PUT with different config`, async () => {
|
|
305
|
+
const streamPath = `/v1/stream/config-conflict-test-${Date.now()}`;
|
|
306
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
307
|
+
method: `PUT`,
|
|
308
|
+
headers: { "Content-Type": `text/plain` }
|
|
309
|
+
});
|
|
310
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
311
|
+
method: `PUT`,
|
|
312
|
+
headers: { "Content-Type": `application/json` }
|
|
313
|
+
});
|
|
314
|
+
expect(response.status).toBe(409);
|
|
315
|
+
});
|
|
316
|
+
test(`should return correct headers on POST`, async () => {
|
|
317
|
+
const streamPath = `/v1/stream/post-headers-test-${Date.now()}`;
|
|
318
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
319
|
+
method: `PUT`,
|
|
320
|
+
headers: { "Content-Type": `text/plain` }
|
|
321
|
+
});
|
|
322
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
323
|
+
method: `POST`,
|
|
324
|
+
headers: { "Content-Type": `text/plain` },
|
|
325
|
+
body: `hello world`
|
|
326
|
+
});
|
|
327
|
+
expect([200, 204]).toContain(response.status);
|
|
328
|
+
expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined();
|
|
329
|
+
});
|
|
330
|
+
test(`should return 404 on POST to non-existent stream`, async () => {
|
|
331
|
+
const streamPath = `/v1/stream/post-404-test-${Date.now()}`;
|
|
332
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
333
|
+
method: `POST`,
|
|
334
|
+
headers: { "Content-Type": `text/plain` },
|
|
335
|
+
body: `data`
|
|
336
|
+
});
|
|
337
|
+
expect(response.status).toBe(404);
|
|
338
|
+
});
|
|
339
|
+
test(`should return 409 on content-type mismatch`, async () => {
|
|
340
|
+
const streamPath = `/v1/stream/content-type-mismatch-test-${Date.now()}`;
|
|
341
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
342
|
+
method: `PUT`,
|
|
343
|
+
headers: { "Content-Type": `text/plain` }
|
|
344
|
+
});
|
|
345
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
346
|
+
method: `POST`,
|
|
347
|
+
headers: { "Content-Type": `application/json` },
|
|
348
|
+
body: `{}`
|
|
349
|
+
});
|
|
350
|
+
expect(response.status).toBe(409);
|
|
351
|
+
});
|
|
352
|
+
test(`should return correct headers on GET`, async () => {
|
|
353
|
+
const streamPath = `/v1/stream/get-headers-test-${Date.now()}`;
|
|
354
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
355
|
+
method: `PUT`,
|
|
356
|
+
headers: { "Content-Type": `text/plain` },
|
|
357
|
+
body: `test data`
|
|
358
|
+
});
|
|
359
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
360
|
+
expect(response.status).toBe(200);
|
|
361
|
+
expect(response.headers.get(`content-type`)).toBe(`text/plain`);
|
|
362
|
+
const nextOffset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
363
|
+
expect(nextOffset).toBeDefined();
|
|
364
|
+
expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
|
|
365
|
+
const etag = response.headers.get(`etag`);
|
|
366
|
+
expect(etag).toBeDefined();
|
|
367
|
+
const text = await response.text();
|
|
368
|
+
expect(text).toBe(`test data`);
|
|
369
|
+
});
|
|
370
|
+
test(`should return empty body with up-to-date for empty stream`, async () => {
|
|
371
|
+
const streamPath = `/v1/stream/get-empty-test-${Date.now()}`;
|
|
372
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
373
|
+
method: `PUT`,
|
|
374
|
+
headers: { "Content-Type": `text/plain` }
|
|
375
|
+
});
|
|
376
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
377
|
+
expect(response.status).toBe(200);
|
|
378
|
+
expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined();
|
|
379
|
+
expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
|
|
380
|
+
const text = await response.text();
|
|
381
|
+
expect(text).toBe(``);
|
|
382
|
+
});
|
|
383
|
+
test(`should read from offset`, async () => {
|
|
384
|
+
const streamPath = `/v1/stream/get-offset-test-${Date.now()}`;
|
|
385
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
386
|
+
method: `PUT`,
|
|
387
|
+
headers: { "Content-Type": `text/plain` },
|
|
388
|
+
body: `first`
|
|
389
|
+
});
|
|
390
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
391
|
+
method: `POST`,
|
|
392
|
+
headers: { "Content-Type": `text/plain` },
|
|
393
|
+
body: `second`
|
|
394
|
+
});
|
|
395
|
+
const firstResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
396
|
+
const firstText = await firstResponse.text();
|
|
397
|
+
expect(firstText).toBe(`firstsecond`);
|
|
398
|
+
const streamPath2 = `/v1/stream/get-offset-test2-${Date.now()}`;
|
|
399
|
+
await fetch(`${getBaseUrl()}${streamPath2}`, {
|
|
400
|
+
method: `PUT`,
|
|
401
|
+
headers: { "Content-Type": `text/plain` },
|
|
402
|
+
body: `first`
|
|
403
|
+
});
|
|
404
|
+
const middleResponse = await fetch(`${getBaseUrl()}${streamPath2}`, { method: `GET` });
|
|
405
|
+
const middleOffset = middleResponse.headers.get(STREAM_OFFSET_HEADER);
|
|
406
|
+
await fetch(`${getBaseUrl()}${streamPath2}`, {
|
|
407
|
+
method: `POST`,
|
|
408
|
+
headers: { "Content-Type": `text/plain` },
|
|
409
|
+
body: `second`
|
|
410
|
+
});
|
|
411
|
+
const response = await fetch(`${getBaseUrl()}${streamPath2}?offset=${middleOffset}`, { method: `GET` });
|
|
412
|
+
expect(response.status).toBe(200);
|
|
413
|
+
const text = await response.text();
|
|
414
|
+
expect(text).toBe(`second`);
|
|
415
|
+
});
|
|
416
|
+
test(`should return 404 on DELETE non-existent stream`, async () => {
|
|
417
|
+
const streamPath = `/v1/stream/delete-404-test-${Date.now()}`;
|
|
418
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `DELETE` });
|
|
419
|
+
expect(response.status).toBe(404);
|
|
420
|
+
});
|
|
421
|
+
test(`should return 204 on successful DELETE`, async () => {
|
|
422
|
+
const streamPath = `/v1/stream/delete-success-test-${Date.now()}`;
|
|
423
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
424
|
+
method: `PUT`,
|
|
425
|
+
headers: { "Content-Type": `text/plain` }
|
|
426
|
+
});
|
|
427
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `DELETE` });
|
|
428
|
+
expect(response.status).toBe(204);
|
|
429
|
+
const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
430
|
+
expect(readResponse.status).toBe(404);
|
|
431
|
+
});
|
|
432
|
+
test(`should enforce sequence ordering`, async () => {
|
|
433
|
+
const streamPath = `/v1/stream/seq-test-${Date.now()}`;
|
|
434
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
435
|
+
method: `PUT`,
|
|
436
|
+
headers: { "Content-Type": `text/plain` }
|
|
437
|
+
});
|
|
438
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
439
|
+
method: `POST`,
|
|
440
|
+
headers: {
|
|
441
|
+
"Content-Type": `text/plain`,
|
|
442
|
+
[STREAM_SEQ_HEADER]: `001`
|
|
443
|
+
},
|
|
444
|
+
body: `first`
|
|
445
|
+
});
|
|
446
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
447
|
+
method: `POST`,
|
|
448
|
+
headers: {
|
|
449
|
+
"Content-Type": `text/plain`,
|
|
450
|
+
[STREAM_SEQ_HEADER]: `002`
|
|
451
|
+
},
|
|
452
|
+
body: `second`
|
|
453
|
+
});
|
|
454
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
455
|
+
method: `POST`,
|
|
456
|
+
headers: {
|
|
457
|
+
"Content-Type": `text/plain`,
|
|
458
|
+
[STREAM_SEQ_HEADER]: `001`
|
|
459
|
+
},
|
|
460
|
+
body: `invalid`
|
|
461
|
+
});
|
|
462
|
+
expect(response.status).toBe(409);
|
|
463
|
+
});
|
|
464
|
+
test(`should enforce lexicographic seq ordering ("2" then "10" rejects)`, async () => {
|
|
465
|
+
const streamPath = `/v1/stream/seq-lexicographic-test-${Date.now()}`;
|
|
466
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
467
|
+
method: `PUT`,
|
|
468
|
+
headers: { "Content-Type": `text/plain` }
|
|
469
|
+
});
|
|
470
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
471
|
+
method: `POST`,
|
|
472
|
+
headers: {
|
|
473
|
+
"Content-Type": `text/plain`,
|
|
474
|
+
[STREAM_SEQ_HEADER]: `2`
|
|
475
|
+
},
|
|
476
|
+
body: `first`
|
|
477
|
+
});
|
|
478
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
479
|
+
method: `POST`,
|
|
480
|
+
headers: {
|
|
481
|
+
"Content-Type": `text/plain`,
|
|
482
|
+
[STREAM_SEQ_HEADER]: `10`
|
|
483
|
+
},
|
|
484
|
+
body: `second`
|
|
485
|
+
});
|
|
486
|
+
expect(response.status).toBe(409);
|
|
487
|
+
});
|
|
488
|
+
test(`should allow lexicographic seq ordering ("09" then "10" succeeds)`, async () => {
|
|
489
|
+
const streamPath = `/v1/stream/seq-padded-test-${Date.now()}`;
|
|
490
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
491
|
+
method: `PUT`,
|
|
492
|
+
headers: { "Content-Type": `text/plain` }
|
|
493
|
+
});
|
|
494
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
495
|
+
method: `POST`,
|
|
496
|
+
headers: {
|
|
497
|
+
"Content-Type": `text/plain`,
|
|
498
|
+
[STREAM_SEQ_HEADER]: `09`
|
|
499
|
+
},
|
|
500
|
+
body: `first`
|
|
501
|
+
});
|
|
502
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
503
|
+
method: `POST`,
|
|
504
|
+
headers: {
|
|
505
|
+
"Content-Type": `text/plain`,
|
|
506
|
+
[STREAM_SEQ_HEADER]: `10`
|
|
507
|
+
},
|
|
508
|
+
body: `second`
|
|
509
|
+
});
|
|
510
|
+
expect([200, 204]).toContain(response.status);
|
|
511
|
+
});
|
|
512
|
+
test(`should reject duplicate seq values`, async () => {
|
|
513
|
+
const streamPath = `/v1/stream/seq-duplicate-test-${Date.now()}`;
|
|
514
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
515
|
+
method: `PUT`,
|
|
516
|
+
headers: { "Content-Type": `text/plain` }
|
|
517
|
+
});
|
|
518
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
519
|
+
method: `POST`,
|
|
520
|
+
headers: {
|
|
521
|
+
"Content-Type": `text/plain`,
|
|
522
|
+
[STREAM_SEQ_HEADER]: `001`
|
|
523
|
+
},
|
|
524
|
+
body: `first`
|
|
525
|
+
});
|
|
526
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
527
|
+
method: `POST`,
|
|
528
|
+
headers: {
|
|
529
|
+
"Content-Type": `text/plain`,
|
|
530
|
+
[STREAM_SEQ_HEADER]: `001`
|
|
531
|
+
},
|
|
532
|
+
body: `duplicate`
|
|
533
|
+
});
|
|
534
|
+
expect(response.status).toBe(409);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
describe(`TTL and Expiry Validation`, () => {
|
|
538
|
+
test(`should reject both TTL and Expires-At (400)`, async () => {
|
|
539
|
+
const streamPath = `/v1/stream/ttl-expires-conflict-test-${Date.now()}`;
|
|
540
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
541
|
+
method: `PUT`,
|
|
542
|
+
headers: {
|
|
543
|
+
"Content-Type": `text/plain`,
|
|
544
|
+
"Stream-TTL": `3600`,
|
|
545
|
+
"Stream-Expires-At": new Date(Date.now() + 36e5).toISOString()
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
expect(response.status).toBe(400);
|
|
549
|
+
});
|
|
550
|
+
test(`should reject invalid TTL (non-integer)`, async () => {
|
|
551
|
+
const streamPath = `/v1/stream/ttl-invalid-test-${Date.now()}`;
|
|
552
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
553
|
+
method: `PUT`,
|
|
554
|
+
headers: {
|
|
555
|
+
"Content-Type": `text/plain`,
|
|
556
|
+
"Stream-TTL": `abc`
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
expect(response.status).toBe(400);
|
|
560
|
+
});
|
|
561
|
+
test(`should reject negative TTL`, async () => {
|
|
562
|
+
const streamPath = `/v1/stream/ttl-negative-test-${Date.now()}`;
|
|
563
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
564
|
+
method: `PUT`,
|
|
565
|
+
headers: {
|
|
566
|
+
"Content-Type": `text/plain`,
|
|
567
|
+
"Stream-TTL": `-1`
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
expect(response.status).toBe(400);
|
|
571
|
+
});
|
|
572
|
+
test(`should accept valid TTL`, async () => {
|
|
573
|
+
const streamPath = `/v1/stream/ttl-valid-test-${Date.now()}`;
|
|
574
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
575
|
+
method: `PUT`,
|
|
576
|
+
headers: {
|
|
577
|
+
"Content-Type": `text/plain`,
|
|
578
|
+
"Stream-TTL": `3600`
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
expect([200, 201]).toContain(response.status);
|
|
582
|
+
});
|
|
583
|
+
test(`should accept valid Expires-At`, async () => {
|
|
584
|
+
const streamPath = `/v1/stream/expires-valid-test-${Date.now()}`;
|
|
585
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
586
|
+
method: `PUT`,
|
|
587
|
+
headers: {
|
|
588
|
+
"Content-Type": `text/plain`,
|
|
589
|
+
"Stream-Expires-At": new Date(Date.now() + 36e5).toISOString()
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
expect([200, 201]).toContain(response.status);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
describe(`Case-Insensitivity`, () => {
|
|
596
|
+
test(`should treat content-type case-insensitively`, async () => {
|
|
597
|
+
const streamPath = `/v1/stream/case-content-type-test-${Date.now()}`;
|
|
598
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
599
|
+
method: `PUT`,
|
|
600
|
+
headers: { "Content-Type": `text/plain` }
|
|
601
|
+
});
|
|
602
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
603
|
+
method: `POST`,
|
|
604
|
+
headers: { "Content-Type": `TEXT/PLAIN` },
|
|
605
|
+
body: `test`
|
|
606
|
+
});
|
|
607
|
+
expect([200, 204]).toContain(response.status);
|
|
608
|
+
});
|
|
609
|
+
test(`should allow idempotent create with different case content-type`, async () => {
|
|
610
|
+
const streamPath = `/v1/stream/case-idempotent-test-${Date.now()}`;
|
|
611
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
612
|
+
method: `PUT`,
|
|
613
|
+
headers: { "Content-Type": `application/json` }
|
|
614
|
+
});
|
|
615
|
+
expect(response1.status).toBe(201);
|
|
616
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
617
|
+
method: `PUT`,
|
|
618
|
+
headers: { "Content-Type": `APPLICATION/JSON` }
|
|
619
|
+
});
|
|
620
|
+
expect([200, 204]).toContain(response2.status);
|
|
621
|
+
});
|
|
622
|
+
test(`should accept headers with different casing`, async () => {
|
|
623
|
+
const streamPath = `/v1/stream/case-header-test-${Date.now()}`;
|
|
624
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
625
|
+
method: `PUT`,
|
|
626
|
+
headers: { "Content-Type": `text/plain` }
|
|
627
|
+
});
|
|
628
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
629
|
+
method: `POST`,
|
|
630
|
+
headers: {
|
|
631
|
+
"content-type": `text/plain`,
|
|
632
|
+
"stream-seq": `001`
|
|
633
|
+
},
|
|
634
|
+
body: `test`
|
|
635
|
+
});
|
|
636
|
+
expect([200, 204]).toContain(response.status);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
describe(`Content-Type Validation`, () => {
|
|
640
|
+
test(`should enforce content-type match on append`, async () => {
|
|
641
|
+
const streamPath = `/v1/stream/content-type-enforcement-test-${Date.now()}`;
|
|
642
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
643
|
+
method: `PUT`,
|
|
644
|
+
headers: { "Content-Type": `text/plain` }
|
|
645
|
+
});
|
|
646
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
647
|
+
method: `POST`,
|
|
648
|
+
headers: { "Content-Type": `application/json` },
|
|
649
|
+
body: `{"test": true}`
|
|
650
|
+
});
|
|
651
|
+
expect(response.status).toBe(409);
|
|
652
|
+
});
|
|
653
|
+
test(`should allow append with matching content-type`, async () => {
|
|
654
|
+
const streamPath = `/v1/stream/content-type-match-test-${Date.now()}`;
|
|
655
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
656
|
+
method: `PUT`,
|
|
657
|
+
headers: { "Content-Type": `application/json` }
|
|
658
|
+
});
|
|
659
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
660
|
+
method: `POST`,
|
|
661
|
+
headers: { "Content-Type": `application/json` },
|
|
662
|
+
body: `{"test": true}`
|
|
663
|
+
});
|
|
664
|
+
expect([200, 204]).toContain(response.status);
|
|
665
|
+
});
|
|
666
|
+
test(`should return stream content-type on GET`, async () => {
|
|
667
|
+
const streamPath = `/v1/stream/content-type-get-test-${Date.now()}`;
|
|
668
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
669
|
+
method: `PUT`,
|
|
670
|
+
headers: { "Content-Type": `application/json` },
|
|
671
|
+
body: `{"initial": true}`
|
|
672
|
+
});
|
|
673
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
674
|
+
expect(response.status).toBe(200);
|
|
675
|
+
expect(response.headers.get(`content-type`)).toBe(`application/json`);
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
describe(`HEAD Metadata`, () => {
|
|
679
|
+
test(`should return metadata without body`, async () => {
|
|
680
|
+
const streamPath = `/v1/stream/head-test-${Date.now()}`;
|
|
681
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
682
|
+
method: `PUT`,
|
|
683
|
+
headers: { "Content-Type": `text/plain` },
|
|
684
|
+
body: `test data`
|
|
685
|
+
});
|
|
686
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
687
|
+
expect(response.status).toBe(200);
|
|
688
|
+
expect(response.headers.get(`content-type`)).toBe(`text/plain`);
|
|
689
|
+
expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined();
|
|
690
|
+
const text = await response.text();
|
|
691
|
+
expect(text).toBe(``);
|
|
692
|
+
});
|
|
693
|
+
test(`should return 404 for non-existent stream`, async () => {
|
|
694
|
+
const streamPath = `/v1/stream/head-404-test-${Date.now()}`;
|
|
695
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
696
|
+
expect(response.status).toBe(404);
|
|
697
|
+
});
|
|
698
|
+
test(`should return tail offset`, async () => {
|
|
699
|
+
const streamPath = `/v1/stream/head-offset-test-${Date.now()}`;
|
|
700
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
701
|
+
method: `PUT`,
|
|
702
|
+
headers: { "Content-Type": `text/plain` }
|
|
703
|
+
});
|
|
704
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
705
|
+
const offset1 = response1.headers.get(STREAM_OFFSET_HEADER);
|
|
706
|
+
expect(offset1).toBeDefined();
|
|
707
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
708
|
+
method: `POST`,
|
|
709
|
+
headers: { "Content-Type": `text/plain` },
|
|
710
|
+
body: `test`
|
|
711
|
+
});
|
|
712
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
713
|
+
const offset2 = response2.headers.get(STREAM_OFFSET_HEADER);
|
|
714
|
+
expect(offset2).toBeDefined();
|
|
715
|
+
expect(offset2).not.toBe(offset1);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
describe(`Offset Validation and Resumability`, () => {
|
|
719
|
+
test(`should accept -1 as sentinel for stream beginning`, async () => {
|
|
720
|
+
const streamPath = `/v1/stream/offset-sentinel-test-${Date.now()}`;
|
|
721
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
722
|
+
method: `PUT`,
|
|
723
|
+
headers: { "Content-Type": `text/plain` },
|
|
724
|
+
body: `test data`
|
|
725
|
+
});
|
|
726
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=-1`, { method: `GET` });
|
|
727
|
+
expect(response.status).toBe(200);
|
|
728
|
+
const text = await response.text();
|
|
729
|
+
expect(text).toBe(`test data`);
|
|
730
|
+
expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
|
|
731
|
+
});
|
|
732
|
+
test(`should return same data for offset=-1 and no offset`, async () => {
|
|
733
|
+
const streamPath = `/v1/stream/offset-sentinel-equiv-test-${Date.now()}`;
|
|
734
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
735
|
+
method: `PUT`,
|
|
736
|
+
headers: { "Content-Type": `text/plain` },
|
|
737
|
+
body: `hello world`
|
|
738
|
+
});
|
|
739
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
740
|
+
const text1 = await response1.text();
|
|
741
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}?offset=-1`, { method: `GET` });
|
|
742
|
+
const text2 = await response2.text();
|
|
743
|
+
expect(text1).toBe(text2);
|
|
744
|
+
expect(text1).toBe(`hello world`);
|
|
745
|
+
});
|
|
746
|
+
test(`should reject malformed offset (contains comma)`, async () => {
|
|
747
|
+
const streamPath = `/v1/stream/offset-comma-test-${Date.now()}`;
|
|
748
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
749
|
+
method: `PUT`,
|
|
750
|
+
headers: { "Content-Type": `text/plain` },
|
|
751
|
+
body: `test`
|
|
752
|
+
});
|
|
753
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=0,1`, { method: `GET` });
|
|
754
|
+
expect(response.status).toBe(400);
|
|
755
|
+
});
|
|
756
|
+
test(`should reject offset with spaces`, async () => {
|
|
757
|
+
const streamPath = `/v1/stream/offset-spaces-test-${Date.now()}`;
|
|
758
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
759
|
+
method: `PUT`,
|
|
760
|
+
headers: { "Content-Type": `text/plain` },
|
|
761
|
+
body: `test`
|
|
762
|
+
});
|
|
763
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=0 1`, { method: `GET` });
|
|
764
|
+
expect(response.status).toBe(400);
|
|
765
|
+
});
|
|
766
|
+
test(`should support resumable reads (no duplicate data)`, async () => {
|
|
767
|
+
const streamPath = `/v1/stream/resumable-test-${Date.now()}`;
|
|
768
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
769
|
+
method: `PUT`,
|
|
770
|
+
headers: { "Content-Type": `text/plain` }
|
|
771
|
+
});
|
|
772
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
773
|
+
method: `POST`,
|
|
774
|
+
headers: { "Content-Type": `text/plain` },
|
|
775
|
+
body: `chunk1`
|
|
776
|
+
});
|
|
777
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
778
|
+
const text1 = await response1.text();
|
|
779
|
+
const offset1 = response1.headers.get(STREAM_OFFSET_HEADER);
|
|
780
|
+
expect(text1).toBe(`chunk1`);
|
|
781
|
+
expect(offset1).toBeDefined();
|
|
782
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
783
|
+
method: `POST`,
|
|
784
|
+
headers: { "Content-Type": `text/plain` },
|
|
785
|
+
body: `chunk2`
|
|
786
|
+
});
|
|
787
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}?offset=${offset1}`, { method: `GET` });
|
|
788
|
+
const text2 = await response2.text();
|
|
789
|
+
expect(text2).toBe(`chunk2`);
|
|
790
|
+
});
|
|
791
|
+
test(`should return empty response when reading from tail offset`, async () => {
|
|
792
|
+
const streamPath = `/v1/stream/tail-read-test-${Date.now()}`;
|
|
793
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
794
|
+
method: `PUT`,
|
|
795
|
+
headers: { "Content-Type": `text/plain` },
|
|
796
|
+
body: `test`
|
|
797
|
+
});
|
|
798
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
799
|
+
const tailOffset = response1.headers.get(STREAM_OFFSET_HEADER);
|
|
800
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}?offset=${tailOffset}`, { method: `GET` });
|
|
801
|
+
expect(response2.status).toBe(200);
|
|
802
|
+
const text = await response2.text();
|
|
803
|
+
expect(text).toBe(``);
|
|
804
|
+
expect(response2.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
describe(`Protocol Edge Cases`, () => {
|
|
808
|
+
test(`should reject empty POST body with 400`, async () => {
|
|
809
|
+
const streamPath = `/v1/stream/empty-append-test-${Date.now()}`;
|
|
810
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
811
|
+
method: `PUT`,
|
|
812
|
+
headers: { "Content-Type": `text/plain` }
|
|
813
|
+
});
|
|
814
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
815
|
+
method: `POST`,
|
|
816
|
+
headers: { "Content-Type": `text/plain` },
|
|
817
|
+
body: ``
|
|
818
|
+
});
|
|
819
|
+
expect(response.status).toBe(400);
|
|
820
|
+
});
|
|
821
|
+
test(`should handle PUT with initial body correctly`, async () => {
|
|
822
|
+
const streamPath = `/v1/stream/put-initial-body-test-${Date.now()}`;
|
|
823
|
+
const initialData = `initial stream content`;
|
|
824
|
+
const putResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
825
|
+
method: `PUT`,
|
|
826
|
+
headers: { "Content-Type": `text/plain` },
|
|
827
|
+
body: initialData
|
|
828
|
+
});
|
|
829
|
+
expect(putResponse.status).toBe(201);
|
|
830
|
+
const nextOffset = putResponse.headers.get(STREAM_OFFSET_HEADER);
|
|
831
|
+
expect(nextOffset).toBeDefined();
|
|
832
|
+
const getResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
833
|
+
const text = await getResponse.text();
|
|
834
|
+
expect(text).toBe(initialData);
|
|
835
|
+
expect(getResponse.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
|
|
836
|
+
});
|
|
837
|
+
test(`should preserve data immutability by position`, async () => {
|
|
838
|
+
const streamPath = `/v1/stream/immutability-test-${Date.now()}`;
|
|
839
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
840
|
+
method: `PUT`,
|
|
841
|
+
headers: { "Content-Type": `text/plain` },
|
|
842
|
+
body: `chunk1`
|
|
843
|
+
});
|
|
844
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
845
|
+
const text1 = await response1.text();
|
|
846
|
+
const offset1 = response1.headers.get(STREAM_OFFSET_HEADER);
|
|
847
|
+
expect(text1).toBe(`chunk1`);
|
|
848
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
849
|
+
method: `POST`,
|
|
850
|
+
headers: { "Content-Type": `text/plain` },
|
|
851
|
+
body: `chunk2`
|
|
852
|
+
});
|
|
853
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
854
|
+
method: `POST`,
|
|
855
|
+
headers: { "Content-Type": `text/plain` },
|
|
856
|
+
body: `chunk3`
|
|
857
|
+
});
|
|
858
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}?offset=${offset1}`, { method: `GET` });
|
|
859
|
+
const text2 = await response2.text();
|
|
860
|
+
expect(text2).toBe(`chunk2chunk3`);
|
|
861
|
+
});
|
|
862
|
+
test(`should generate unique, monotonically increasing offsets`, async () => {
|
|
863
|
+
const streamPath = `/v1/stream/monotonic-offset-test-${Date.now()}`;
|
|
864
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
865
|
+
method: `PUT`,
|
|
866
|
+
headers: { "Content-Type": `text/plain` }
|
|
867
|
+
});
|
|
868
|
+
const offsets = [];
|
|
869
|
+
for (let i = 0; i < 5; i++) {
|
|
870
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
871
|
+
method: `POST`,
|
|
872
|
+
headers: { "Content-Type": `text/plain` },
|
|
873
|
+
body: `chunk${i}`
|
|
874
|
+
});
|
|
875
|
+
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
876
|
+
expect(offset).toBeDefined();
|
|
877
|
+
offsets.push(offset);
|
|
878
|
+
}
|
|
879
|
+
for (let i = 1; i < offsets.length; i++) expect(offsets[i] > offsets[i - 1]).toBe(true);
|
|
880
|
+
});
|
|
881
|
+
test(`should reject empty offset parameter`, async () => {
|
|
882
|
+
const streamPath = `/v1/stream/empty-offset-test-${Date.now()}`;
|
|
883
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
884
|
+
method: `PUT`,
|
|
885
|
+
headers: { "Content-Type": `text/plain` },
|
|
886
|
+
body: `test`
|
|
887
|
+
});
|
|
888
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=`, { method: `GET` });
|
|
889
|
+
expect(response.status).toBe(400);
|
|
890
|
+
});
|
|
891
|
+
test(`should reject multiple offset parameters`, async () => {
|
|
892
|
+
const streamPath = `/v1/stream/multi-offset-test-${Date.now()}`;
|
|
893
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
894
|
+
method: `PUT`,
|
|
895
|
+
headers: { "Content-Type": `text/plain` },
|
|
896
|
+
body: `test`
|
|
897
|
+
});
|
|
898
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=a&offset=b`, { method: `GET` });
|
|
899
|
+
expect(response.status).toBe(400);
|
|
900
|
+
});
|
|
901
|
+
test(`should enforce case-sensitive seq ordering`, async () => {
|
|
902
|
+
const streamPath = `/v1/stream/case-seq-test-${Date.now()}`;
|
|
903
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
904
|
+
method: `PUT`,
|
|
905
|
+
headers: { "Content-Type": `text/plain` }
|
|
906
|
+
});
|
|
907
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
908
|
+
method: `POST`,
|
|
909
|
+
headers: {
|
|
910
|
+
"Content-Type": `text/plain`,
|
|
911
|
+
[STREAM_SEQ_HEADER]: `a`
|
|
912
|
+
},
|
|
913
|
+
body: `first`
|
|
914
|
+
});
|
|
915
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
916
|
+
method: `POST`,
|
|
917
|
+
headers: {
|
|
918
|
+
"Content-Type": `text/plain`,
|
|
919
|
+
[STREAM_SEQ_HEADER]: `B`
|
|
920
|
+
},
|
|
921
|
+
body: `second`
|
|
922
|
+
});
|
|
923
|
+
expect(response.status).toBe(409);
|
|
924
|
+
});
|
|
925
|
+
test(`should handle binary data with integrity`, async () => {
|
|
926
|
+
const streamPath = `/v1/stream/binary-test-${Date.now()}`;
|
|
927
|
+
const binaryData = new Uint8Array([
|
|
928
|
+
0,
|
|
929
|
+
1,
|
|
930
|
+
2,
|
|
931
|
+
127,
|
|
932
|
+
128,
|
|
933
|
+
254,
|
|
934
|
+
255
|
|
935
|
+
]);
|
|
936
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
937
|
+
method: `PUT`,
|
|
938
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
939
|
+
body: binaryData
|
|
940
|
+
});
|
|
941
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
942
|
+
const buffer = await response.arrayBuffer();
|
|
943
|
+
const result = new Uint8Array(buffer);
|
|
944
|
+
expect(result.length).toBe(binaryData.length);
|
|
945
|
+
for (let i = 0; i < binaryData.length; i++) expect(result[i]).toBe(binaryData[i]);
|
|
946
|
+
});
|
|
947
|
+
test(`should return Location header on 201`, async () => {
|
|
948
|
+
const streamPath = `/v1/stream/location-test-${Date.now()}`;
|
|
949
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
950
|
+
method: `PUT`,
|
|
951
|
+
headers: { "Content-Type": `text/plain` }
|
|
952
|
+
});
|
|
953
|
+
expect(response.status).toBe(201);
|
|
954
|
+
const location = response.headers.get(`location`);
|
|
955
|
+
expect(location).toBeDefined();
|
|
956
|
+
expect(location).toBe(`${getBaseUrl()}${streamPath}`);
|
|
957
|
+
});
|
|
958
|
+
test(`should reject missing Content-Type on POST`, async () => {
|
|
959
|
+
const streamPath = `/v1/stream/missing-ct-post-test-${Date.now()}`;
|
|
960
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
961
|
+
method: `PUT`,
|
|
962
|
+
headers: { "Content-Type": `text/plain` }
|
|
963
|
+
});
|
|
964
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
965
|
+
method: `POST`,
|
|
966
|
+
body: new Blob([`data`], { type: `` })
|
|
967
|
+
});
|
|
968
|
+
expect(response.status).toBe(400);
|
|
969
|
+
});
|
|
970
|
+
test(`should accept PUT without Content-Type (use default)`, async () => {
|
|
971
|
+
const streamPath = `/v1/stream/no-ct-put-test-${Date.now()}`;
|
|
972
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `PUT` });
|
|
973
|
+
expect([200, 201]).toContain(response.status);
|
|
974
|
+
const contentType = response.headers.get(`content-type`);
|
|
975
|
+
expect(contentType).toBeDefined();
|
|
976
|
+
});
|
|
977
|
+
test(`should ignore unknown query parameters`, async () => {
|
|
978
|
+
const streamPath = `/v1/stream/unknown-param-test-${Date.now()}`;
|
|
979
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
980
|
+
method: `PUT`,
|
|
981
|
+
headers: { "Content-Type": `text/plain` },
|
|
982
|
+
body: `test data`
|
|
983
|
+
});
|
|
984
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=-1&foo=bar&baz=qux`, { method: `GET` });
|
|
985
|
+
expect(response.status).toBe(200);
|
|
986
|
+
const text = await response.text();
|
|
987
|
+
expect(text).toBe(`test data`);
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
describe(`Long-Poll Edge Cases`, () => {
|
|
991
|
+
test(`should require offset parameter for long-poll`, async () => {
|
|
992
|
+
const streamPath = `/v1/stream/longpoll-no-offset-test-${Date.now()}`;
|
|
993
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
994
|
+
method: `PUT`,
|
|
995
|
+
headers: { "Content-Type": `text/plain` }
|
|
996
|
+
});
|
|
997
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?live=long-poll`, { method: `GET` });
|
|
998
|
+
expect(response.status).toBe(400);
|
|
999
|
+
});
|
|
1000
|
+
test(`should generate Stream-Cursor header on long-poll responses`, async () => {
|
|
1001
|
+
const streamPath = `/v1/stream/longpoll-cursor-gen-test-${Date.now()}`;
|
|
1002
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1003
|
+
method: `PUT`,
|
|
1004
|
+
headers: { "Content-Type": `text/plain` },
|
|
1005
|
+
body: `test data`
|
|
1006
|
+
});
|
|
1007
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=-1&live=long-poll`, { method: `GET` });
|
|
1008
|
+
expect(response.status).toBe(200);
|
|
1009
|
+
const cursor = response.headers.get(`Stream-Cursor`);
|
|
1010
|
+
expect(cursor).toBeDefined();
|
|
1011
|
+
expect(cursor).not.toBeNull();
|
|
1012
|
+
expect(/^\d+$/.test(cursor)).toBe(true);
|
|
1013
|
+
});
|
|
1014
|
+
test(`should echo cursor and handle collision with jitter`, async () => {
|
|
1015
|
+
const streamPath = `/v1/stream/longpoll-cursor-collision-test-${Date.now()}`;
|
|
1016
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1017
|
+
method: `PUT`,
|
|
1018
|
+
headers: { "Content-Type": `text/plain` },
|
|
1019
|
+
body: `test data`
|
|
1020
|
+
});
|
|
1021
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}?offset=-1&live=long-poll`, { method: `GET` });
|
|
1022
|
+
expect(response1.status).toBe(200);
|
|
1023
|
+
const cursor1 = response1.headers.get(`Stream-Cursor`);
|
|
1024
|
+
expect(cursor1).toBeDefined();
|
|
1025
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}?offset=-1&live=long-poll&cursor=${cursor1}`, { method: `GET` });
|
|
1026
|
+
expect(response2.status).toBe(200);
|
|
1027
|
+
const cursor2 = response2.headers.get(`Stream-Cursor`);
|
|
1028
|
+
expect(cursor2).toBeDefined();
|
|
1029
|
+
expect(parseInt(cursor2, 10)).toBeGreaterThan(parseInt(cursor1, 10));
|
|
1030
|
+
});
|
|
1031
|
+
test(`should return Stream-Cursor, Stream-Up-To-Date and Stream-Next-Offset on 204 timeout`, async () => {
|
|
1032
|
+
const streamPath = `/v1/stream/longpoll-204-headers-test-${Date.now()}`;
|
|
1033
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1034
|
+
method: `PUT`,
|
|
1035
|
+
headers: { "Content-Type": `text/plain` }
|
|
1036
|
+
});
|
|
1037
|
+
const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
1038
|
+
const tailOffset = headResponse.headers.get(STREAM_OFFSET_HEADER);
|
|
1039
|
+
expect(tailOffset).toBeDefined();
|
|
1040
|
+
const controller = new AbortController();
|
|
1041
|
+
const timeoutId = setTimeout(() => controller.abort(), 5e3);
|
|
1042
|
+
try {
|
|
1043
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=${tailOffset}&live=long-poll`, {
|
|
1044
|
+
method: `GET`,
|
|
1045
|
+
signal: controller.signal
|
|
1046
|
+
});
|
|
1047
|
+
clearTimeout(timeoutId);
|
|
1048
|
+
if (response.status === 204) {
|
|
1049
|
+
expect(response.headers.get(STREAM_OFFSET_HEADER)).toBeDefined();
|
|
1050
|
+
expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
|
|
1051
|
+
const cursor = response.headers.get(`Stream-Cursor`);
|
|
1052
|
+
expect(cursor).toBeDefined();
|
|
1053
|
+
expect(/^\d+$/.test(cursor)).toBe(true);
|
|
1054
|
+
}
|
|
1055
|
+
expect([200, 204]).toContain(response.status);
|
|
1056
|
+
} catch (e) {
|
|
1057
|
+
clearTimeout(timeoutId);
|
|
1058
|
+
if (e instanceof Error && e.name !== `AbortError`) throw e;
|
|
1059
|
+
}
|
|
1060
|
+
}, 1e4);
|
|
1061
|
+
});
|
|
1062
|
+
describe(`TTL and Expiry Edge Cases`, () => {
|
|
1063
|
+
test(`should reject TTL with leading zeros`, async () => {
|
|
1064
|
+
const streamPath = `/v1/stream/ttl-leading-zeros-test-${Date.now()}`;
|
|
1065
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1066
|
+
method: `PUT`,
|
|
1067
|
+
headers: {
|
|
1068
|
+
"Content-Type": `text/plain`,
|
|
1069
|
+
"Stream-TTL": `00060`
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
expect(response.status).toBe(400);
|
|
1073
|
+
});
|
|
1074
|
+
test(`should reject TTL with plus sign`, async () => {
|
|
1075
|
+
const streamPath = `/v1/stream/ttl-plus-test-${Date.now()}`;
|
|
1076
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1077
|
+
method: `PUT`,
|
|
1078
|
+
headers: {
|
|
1079
|
+
"Content-Type": `text/plain`,
|
|
1080
|
+
"Stream-TTL": `+60`
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
expect(response.status).toBe(400);
|
|
1084
|
+
});
|
|
1085
|
+
test(`should reject TTL with float value`, async () => {
|
|
1086
|
+
const streamPath = `/v1/stream/ttl-float-test-${Date.now()}`;
|
|
1087
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1088
|
+
method: `PUT`,
|
|
1089
|
+
headers: {
|
|
1090
|
+
"Content-Type": `text/plain`,
|
|
1091
|
+
"Stream-TTL": `60.5`
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
expect(response.status).toBe(400);
|
|
1095
|
+
});
|
|
1096
|
+
test(`should reject TTL with scientific notation`, async () => {
|
|
1097
|
+
const streamPath = `/v1/stream/ttl-scientific-test-${Date.now()}`;
|
|
1098
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1099
|
+
method: `PUT`,
|
|
1100
|
+
headers: {
|
|
1101
|
+
"Content-Type": `text/plain`,
|
|
1102
|
+
"Stream-TTL": `1e3`
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
expect(response.status).toBe(400);
|
|
1106
|
+
});
|
|
1107
|
+
test(`should reject invalid Expires-At timestamp`, async () => {
|
|
1108
|
+
const streamPath = `/v1/stream/expires-invalid-test-${Date.now()}`;
|
|
1109
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1110
|
+
method: `PUT`,
|
|
1111
|
+
headers: {
|
|
1112
|
+
"Content-Type": `text/plain`,
|
|
1113
|
+
"Stream-Expires-At": `not-a-timestamp`
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
expect(response.status).toBe(400);
|
|
1117
|
+
});
|
|
1118
|
+
test(`should accept Expires-At with Z timezone`, async () => {
|
|
1119
|
+
const streamPath = `/v1/stream/expires-z-test-${Date.now()}`;
|
|
1120
|
+
const expiresAt = new Date(Date.now() + 36e5).toISOString();
|
|
1121
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1122
|
+
method: `PUT`,
|
|
1123
|
+
headers: {
|
|
1124
|
+
"Content-Type": `text/plain`,
|
|
1125
|
+
"Stream-Expires-At": expiresAt
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
expect([200, 201]).toContain(response.status);
|
|
1129
|
+
});
|
|
1130
|
+
test(`should accept Expires-At with timezone offset`, async () => {
|
|
1131
|
+
const streamPath = `/v1/stream/expires-offset-test-${Date.now()}`;
|
|
1132
|
+
const date = new Date(Date.now() + 36e5);
|
|
1133
|
+
const expiresAt = date.toISOString().replace(`Z`, `+00:00`);
|
|
1134
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1135
|
+
method: `PUT`,
|
|
1136
|
+
headers: {
|
|
1137
|
+
"Content-Type": `text/plain`,
|
|
1138
|
+
"Stream-Expires-At": expiresAt
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
expect([200, 201]).toContain(response.status);
|
|
1142
|
+
});
|
|
1143
|
+
test(`should handle idempotent PUT with same TTL`, async () => {
|
|
1144
|
+
const streamPath = `/v1/stream/ttl-idempotent-test-${Date.now()}`;
|
|
1145
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1146
|
+
method: `PUT`,
|
|
1147
|
+
headers: {
|
|
1148
|
+
"Content-Type": `text/plain`,
|
|
1149
|
+
"Stream-TTL": `3600`
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
expect(response1.status).toBe(201);
|
|
1153
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1154
|
+
method: `PUT`,
|
|
1155
|
+
headers: {
|
|
1156
|
+
"Content-Type": `text/plain`,
|
|
1157
|
+
"Stream-TTL": `3600`
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
expect([200, 204]).toContain(response2.status);
|
|
1161
|
+
});
|
|
1162
|
+
test(`should reject idempotent PUT with different TTL`, async () => {
|
|
1163
|
+
const streamPath = `/v1/stream/ttl-conflict-test-${Date.now()}`;
|
|
1164
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1165
|
+
method: `PUT`,
|
|
1166
|
+
headers: {
|
|
1167
|
+
"Content-Type": `text/plain`,
|
|
1168
|
+
"Stream-TTL": `3600`
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1172
|
+
method: `PUT`,
|
|
1173
|
+
headers: {
|
|
1174
|
+
"Content-Type": `text/plain`,
|
|
1175
|
+
"Stream-TTL": `7200`
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
expect(response.status).toBe(409);
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
describe(`HEAD Metadata Edge Cases`, () => {
|
|
1182
|
+
test(`should return TTL metadata if configured`, async () => {
|
|
1183
|
+
const streamPath = `/v1/stream/head-ttl-metadata-test-${Date.now()}`;
|
|
1184
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1185
|
+
method: `PUT`,
|
|
1186
|
+
headers: {
|
|
1187
|
+
"Content-Type": `text/plain`,
|
|
1188
|
+
"Stream-TTL": `3600`
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
1192
|
+
const ttl = response.headers.get(`Stream-TTL`);
|
|
1193
|
+
if (ttl) {
|
|
1194
|
+
expect(parseInt(ttl)).toBeGreaterThan(0);
|
|
1195
|
+
expect(parseInt(ttl)).toBeLessThanOrEqual(3600);
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
test(`should return Expires-At metadata if configured`, async () => {
|
|
1199
|
+
const streamPath = `/v1/stream/head-expires-metadata-test-${Date.now()}`;
|
|
1200
|
+
const expiresAt = new Date(Date.now() + 36e5).toISOString();
|
|
1201
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1202
|
+
method: `PUT`,
|
|
1203
|
+
headers: {
|
|
1204
|
+
"Content-Type": `text/plain`,
|
|
1205
|
+
"Stream-Expires-At": expiresAt
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
|
|
1209
|
+
const expiresHeader = response.headers.get(`Stream-Expires-At`);
|
|
1210
|
+
if (expiresHeader) expect(expiresHeader).toBeDefined();
|
|
1211
|
+
});
|
|
1212
|
+
});
|
|
1213
|
+
describe(`Caching and ETag`, () => {
|
|
1214
|
+
test(`should generate ETag on GET responses`, async () => {
|
|
1215
|
+
const streamPath = `/v1/stream/etag-generate-test-${Date.now()}`;
|
|
1216
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1217
|
+
method: `PUT`,
|
|
1218
|
+
headers: { "Content-Type": `text/plain` },
|
|
1219
|
+
body: `test data`
|
|
1220
|
+
});
|
|
1221
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
1222
|
+
expect(response.status).toBe(200);
|
|
1223
|
+
const etag = response.headers.get(`etag`);
|
|
1224
|
+
expect(etag).toBeDefined();
|
|
1225
|
+
expect(etag.length).toBeGreaterThan(0);
|
|
1226
|
+
});
|
|
1227
|
+
test(`should return 304 Not Modified for matching If-None-Match`, async () => {
|
|
1228
|
+
const streamPath = `/v1/stream/etag-304-test-${Date.now()}`;
|
|
1229
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1230
|
+
method: `PUT`,
|
|
1231
|
+
headers: { "Content-Type": `text/plain` },
|
|
1232
|
+
body: `test data`
|
|
1233
|
+
});
|
|
1234
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
1235
|
+
const etag = response1.headers.get(`etag`);
|
|
1236
|
+
expect(etag).toBeDefined();
|
|
1237
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1238
|
+
method: `GET`,
|
|
1239
|
+
headers: { "If-None-Match": etag }
|
|
1240
|
+
});
|
|
1241
|
+
expect(response2.status).toBe(304);
|
|
1242
|
+
const text = await response2.text();
|
|
1243
|
+
expect(text).toBe(``);
|
|
1244
|
+
});
|
|
1245
|
+
test(`should return 200 for non-matching If-None-Match`, async () => {
|
|
1246
|
+
const streamPath = `/v1/stream/etag-mismatch-test-${Date.now()}`;
|
|
1247
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1248
|
+
method: `PUT`,
|
|
1249
|
+
headers: { "Content-Type": `text/plain` },
|
|
1250
|
+
body: `test data`
|
|
1251
|
+
});
|
|
1252
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1253
|
+
method: `GET`,
|
|
1254
|
+
headers: { "If-None-Match": `"wrong-etag"` }
|
|
1255
|
+
});
|
|
1256
|
+
expect(response.status).toBe(200);
|
|
1257
|
+
const text = await response.text();
|
|
1258
|
+
expect(text).toBe(`test data`);
|
|
1259
|
+
});
|
|
1260
|
+
test(`should return new ETag after data changes`, async () => {
|
|
1261
|
+
const streamPath = `/v1/stream/etag-change-test-${Date.now()}`;
|
|
1262
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1263
|
+
method: `PUT`,
|
|
1264
|
+
headers: { "Content-Type": `text/plain` },
|
|
1265
|
+
body: `initial`
|
|
1266
|
+
});
|
|
1267
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
1268
|
+
const etag1 = response1.headers.get(`etag`);
|
|
1269
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1270
|
+
method: `POST`,
|
|
1271
|
+
headers: { "Content-Type": `text/plain` },
|
|
1272
|
+
body: ` more`
|
|
1273
|
+
});
|
|
1274
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
1275
|
+
const etag2 = response2.headers.get(`etag`);
|
|
1276
|
+
expect(etag1).not.toBe(etag2);
|
|
1277
|
+
const response3 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1278
|
+
method: `GET`,
|
|
1279
|
+
headers: { "If-None-Match": etag1 }
|
|
1280
|
+
});
|
|
1281
|
+
expect(response3.status).toBe(200);
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
describe(`Chunking and Large Payloads`, () => {
|
|
1285
|
+
test(`should handle chunk-size pagination correctly`, async () => {
|
|
1286
|
+
const streamPath = `/v1/stream/chunk-pagination-test-${Date.now()}`;
|
|
1287
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1288
|
+
method: `PUT`,
|
|
1289
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
1290
|
+
});
|
|
1291
|
+
const largeData = new Uint8Array(100 * 1024);
|
|
1292
|
+
for (let i = 0; i < largeData.length; i++) largeData[i] = i % 256;
|
|
1293
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1294
|
+
method: `POST`,
|
|
1295
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
1296
|
+
body: largeData
|
|
1297
|
+
});
|
|
1298
|
+
const accumulated = [];
|
|
1299
|
+
let currentOffset = null;
|
|
1300
|
+
let previousOffset = null;
|
|
1301
|
+
let iterations = 0;
|
|
1302
|
+
const maxIterations = 1e3;
|
|
1303
|
+
while (iterations < maxIterations) {
|
|
1304
|
+
iterations++;
|
|
1305
|
+
const url = currentOffset ? `${getBaseUrl()}${streamPath}?offset=${encodeURIComponent(currentOffset)}` : `${getBaseUrl()}${streamPath}`;
|
|
1306
|
+
const response = await fetch(url, { method: `GET` });
|
|
1307
|
+
expect(response.status).toBe(200);
|
|
1308
|
+
const buffer = await response.arrayBuffer();
|
|
1309
|
+
const data = new Uint8Array(buffer);
|
|
1310
|
+
if (data.length > 0) accumulated.push(...Array.from(data));
|
|
1311
|
+
const nextOffset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
1312
|
+
const upToDate = response.headers.get(STREAM_UP_TO_DATE_HEADER);
|
|
1313
|
+
if (upToDate === `true` && data.length === 0) break;
|
|
1314
|
+
expect(nextOffset).toBeDefined();
|
|
1315
|
+
if (nextOffset === currentOffset && data.length === 0) break;
|
|
1316
|
+
if (previousOffset && nextOffset) expect(nextOffset >= previousOffset).toBe(true);
|
|
1317
|
+
previousOffset = currentOffset;
|
|
1318
|
+
currentOffset = nextOffset;
|
|
1319
|
+
}
|
|
1320
|
+
const result = new Uint8Array(accumulated);
|
|
1321
|
+
expect(result.length).toBe(largeData.length);
|
|
1322
|
+
for (let i = 0; i < largeData.length; i++) expect(result[i]).toBe(largeData[i]);
|
|
1323
|
+
});
|
|
1324
|
+
test(`should handle large payload appropriately`, async () => {
|
|
1325
|
+
const streamPath = `/v1/stream/large-payload-test-${Date.now()}`;
|
|
1326
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1327
|
+
method: `PUT`,
|
|
1328
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
1329
|
+
});
|
|
1330
|
+
const largeData = new Uint8Array(10 * 1024 * 1024);
|
|
1331
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1332
|
+
method: `POST`,
|
|
1333
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
1334
|
+
body: largeData
|
|
1335
|
+
});
|
|
1336
|
+
expect([
|
|
1337
|
+
200,
|
|
1338
|
+
204,
|
|
1339
|
+
413
|
|
1340
|
+
]).toContain(response.status);
|
|
1341
|
+
}, 3e4);
|
|
1342
|
+
});
|
|
1343
|
+
describe(`Read-Your-Writes Consistency`, () => {
|
|
1344
|
+
test(`should immediately read message after append`, async () => {
|
|
1345
|
+
const streamPath = `/v1/stream/ryw-test-${Date.now()}`;
|
|
1346
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1347
|
+
method: `PUT`,
|
|
1348
|
+
headers: { "Content-Type": `text/plain` },
|
|
1349
|
+
body: `initial`
|
|
1350
|
+
});
|
|
1351
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
1352
|
+
const text = await response.text();
|
|
1353
|
+
expect(text).toBe(`initial`);
|
|
1354
|
+
});
|
|
1355
|
+
test(`should immediately read multiple appends`, async () => {
|
|
1356
|
+
const streamPath = `/v1/stream/ryw-multi-test-${Date.now()}`;
|
|
1357
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1358
|
+
method: `PUT`,
|
|
1359
|
+
headers: { "Content-Type": `text/plain` }
|
|
1360
|
+
});
|
|
1361
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1362
|
+
method: `POST`,
|
|
1363
|
+
headers: { "Content-Type": `text/plain` },
|
|
1364
|
+
body: `msg1`
|
|
1365
|
+
});
|
|
1366
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1367
|
+
method: `POST`,
|
|
1368
|
+
headers: { "Content-Type": `text/plain` },
|
|
1369
|
+
body: `msg2`
|
|
1370
|
+
});
|
|
1371
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1372
|
+
method: `POST`,
|
|
1373
|
+
headers: { "Content-Type": `text/plain` },
|
|
1374
|
+
body: `msg3`
|
|
1375
|
+
});
|
|
1376
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
1377
|
+
const text = await response.text();
|
|
1378
|
+
expect(text).toBe(`msg1msg2msg3`);
|
|
1379
|
+
});
|
|
1380
|
+
test(`should serve offset-based reads immediately after append`, async () => {
|
|
1381
|
+
const streamPath = `/v1/stream/ryw-offset-test-${Date.now()}`;
|
|
1382
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1383
|
+
method: `PUT`,
|
|
1384
|
+
headers: { "Content-Type": `text/plain` },
|
|
1385
|
+
body: `first`
|
|
1386
|
+
});
|
|
1387
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
|
|
1388
|
+
const offset1 = response1.headers.get(STREAM_OFFSET_HEADER);
|
|
1389
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1390
|
+
method: `POST`,
|
|
1391
|
+
headers: { "Content-Type": `text/plain` },
|
|
1392
|
+
body: `second`
|
|
1393
|
+
});
|
|
1394
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1395
|
+
method: `POST`,
|
|
1396
|
+
headers: { "Content-Type": `text/plain` },
|
|
1397
|
+
body: `third`
|
|
1398
|
+
});
|
|
1399
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}?offset=${offset1}`, { method: `GET` });
|
|
1400
|
+
const text = await response2.text();
|
|
1401
|
+
expect(text).toBe(`secondthird`);
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
describe(`SSE Mode`, () => {
|
|
1405
|
+
test(`should return text/event-stream content-type for SSE requests`, async () => {
|
|
1406
|
+
const streamPath = `/v1/stream/sse-content-type-test-${Date.now()}`;
|
|
1407
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1408
|
+
method: `PUT`,
|
|
1409
|
+
headers: { "Content-Type": `text/plain` },
|
|
1410
|
+
body: `test data`
|
|
1411
|
+
});
|
|
1412
|
+
const { response } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, {
|
|
1413
|
+
headers: { Accept: `text/event-stream` },
|
|
1414
|
+
maxChunks: 0
|
|
1415
|
+
});
|
|
1416
|
+
expect(response.status).toBe(200);
|
|
1417
|
+
expect(response.headers.get(`content-type`)).toBe(`text/event-stream`);
|
|
1418
|
+
});
|
|
1419
|
+
test(`should accept live=sse query parameter for application/json`, async () => {
|
|
1420
|
+
const streamPath = `/v1/stream/sse-json-test-${Date.now()}`;
|
|
1421
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1422
|
+
method: `PUT`,
|
|
1423
|
+
headers: { "Content-Type": `application/json` },
|
|
1424
|
+
body: JSON.stringify({ message: `hello` })
|
|
1425
|
+
});
|
|
1426
|
+
const { response } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, {
|
|
1427
|
+
headers: { Accept: `text/event-stream` },
|
|
1428
|
+
maxChunks: 0
|
|
1429
|
+
});
|
|
1430
|
+
expect(response.status).toBe(200);
|
|
1431
|
+
expect(response.headers.get(`content-type`)).toBe(`text/event-stream`);
|
|
1432
|
+
});
|
|
1433
|
+
test(`should require offset parameter for SSE mode`, async () => {
|
|
1434
|
+
const streamPath = `/v1/stream/sse-no-offset-test-${Date.now()}`;
|
|
1435
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1436
|
+
method: `PUT`,
|
|
1437
|
+
headers: { "Content-Type": `text/plain` }
|
|
1438
|
+
});
|
|
1439
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?live=sse`, { method: `GET` });
|
|
1440
|
+
expect(response.status).toBe(400);
|
|
1441
|
+
});
|
|
1442
|
+
test(`client should reject SSE mode for incompatible content types`, async () => {
|
|
1443
|
+
const streamPath = `/v1/stream/sse-binary-test-${Date.now()}`;
|
|
1444
|
+
const stream = await DurableStream.create({
|
|
1445
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1446
|
+
contentType: `application/octet-stream`
|
|
1447
|
+
});
|
|
1448
|
+
await stream.append(new Uint8Array([
|
|
1449
|
+
1,
|
|
1450
|
+
2,
|
|
1451
|
+
3
|
|
1452
|
+
]));
|
|
1453
|
+
await expect(stream.stream({ live: `sse` })).rejects.toThrow();
|
|
1454
|
+
});
|
|
1455
|
+
test(`should stream data events via SSE`, async () => {
|
|
1456
|
+
const streamPath = `/v1/stream/sse-data-stream-test-${Date.now()}`;
|
|
1457
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1458
|
+
method: `PUT`,
|
|
1459
|
+
headers: { "Content-Type": `text/plain` },
|
|
1460
|
+
body: `message one`
|
|
1461
|
+
});
|
|
1462
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1463
|
+
method: `POST`,
|
|
1464
|
+
headers: { "Content-Type": `text/plain` },
|
|
1465
|
+
body: `message two`
|
|
1466
|
+
});
|
|
1467
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `message two` });
|
|
1468
|
+
expect(response.status).toBe(200);
|
|
1469
|
+
expect(received).toContain(`event:`);
|
|
1470
|
+
expect(received).toContain(`data:`);
|
|
1471
|
+
});
|
|
1472
|
+
test(`should send control events with offset`, async () => {
|
|
1473
|
+
const streamPath = `/v1/stream/sse-control-event-test-${Date.now()}`;
|
|
1474
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1475
|
+
method: `PUT`,
|
|
1476
|
+
headers: { "Content-Type": `text/plain` },
|
|
1477
|
+
body: `test data`
|
|
1478
|
+
});
|
|
1479
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1480
|
+
expect(response.status).toBe(200);
|
|
1481
|
+
expect(received).toContain(`event: control`);
|
|
1482
|
+
expect(received).toContain(`streamNextOffset`);
|
|
1483
|
+
});
|
|
1484
|
+
test(`should generate streamCursor in SSE control events`, async () => {
|
|
1485
|
+
const streamPath = `/v1/stream/sse-cursor-gen-test-${Date.now()}`;
|
|
1486
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1487
|
+
method: `PUT`,
|
|
1488
|
+
headers: { "Content-Type": `text/plain` },
|
|
1489
|
+
body: `test data`
|
|
1490
|
+
});
|
|
1491
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `streamCursor` });
|
|
1492
|
+
expect(response.status).toBe(200);
|
|
1493
|
+
const controlMatch = received.match(/event: control\s*\ndata: ({[^}]+})/);
|
|
1494
|
+
expect(controlMatch).toBeDefined();
|
|
1495
|
+
const controlData = JSON.parse(controlMatch[1]);
|
|
1496
|
+
expect(controlData.streamCursor).toBeDefined();
|
|
1497
|
+
expect(/^\d+$/.test(controlData.streamCursor)).toBe(true);
|
|
1498
|
+
});
|
|
1499
|
+
test(`should handle cursor collision with jitter in SSE mode`, async () => {
|
|
1500
|
+
const streamPath = `/v1/stream/sse-cursor-collision-test-${Date.now()}`;
|
|
1501
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1502
|
+
method: `PUT`,
|
|
1503
|
+
headers: { "Content-Type": `text/plain` },
|
|
1504
|
+
body: `test data`
|
|
1505
|
+
});
|
|
1506
|
+
const { received: received1 } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `streamCursor` });
|
|
1507
|
+
const controlMatch1 = received1.match(/event: control\s*\ndata: ({[^}]+})/);
|
|
1508
|
+
expect(controlMatch1).toBeDefined();
|
|
1509
|
+
const cursor1 = JSON.parse(controlMatch1[1]).streamCursor;
|
|
1510
|
+
const { received: received2 } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse&cursor=${cursor1}`, { untilContent: `streamCursor` });
|
|
1511
|
+
const controlMatch2 = received2.match(/event: control\s*\ndata: ({[^}]+})/);
|
|
1512
|
+
expect(controlMatch2).toBeDefined();
|
|
1513
|
+
const cursor2 = JSON.parse(controlMatch2[1]).streamCursor;
|
|
1514
|
+
expect(parseInt(cursor2, 10)).toBeGreaterThan(parseInt(cursor1, 10));
|
|
1515
|
+
});
|
|
1516
|
+
test(`should wrap JSON data in arrays for SSE and produce valid JSON`, async () => {
|
|
1517
|
+
const streamPath = `/v1/stream/sse-json-wrap-test-${Date.now()}`;
|
|
1518
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1519
|
+
method: `PUT`,
|
|
1520
|
+
headers: { "Content-Type": `application/json` },
|
|
1521
|
+
body: JSON.stringify({
|
|
1522
|
+
id: 1,
|
|
1523
|
+
message: `hello`
|
|
1524
|
+
})
|
|
1525
|
+
});
|
|
1526
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: data` });
|
|
1527
|
+
expect(response.status).toBe(200);
|
|
1528
|
+
expect(received).toContain(`event: data`);
|
|
1529
|
+
const events = parseSSEEvents(received);
|
|
1530
|
+
const dataEvent = events.find((e) => e.type === `data`);
|
|
1531
|
+
expect(dataEvent).toBeDefined();
|
|
1532
|
+
const parsed = JSON.parse(dataEvent.data);
|
|
1533
|
+
expect(parsed).toEqual([{
|
|
1534
|
+
id: 1,
|
|
1535
|
+
message: `hello`
|
|
1536
|
+
}]);
|
|
1537
|
+
});
|
|
1538
|
+
test(`should handle SSE for empty stream with correct offset`, async () => {
|
|
1539
|
+
const streamPath = `/v1/stream/sse-empty-test-${Date.now()}`;
|
|
1540
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1541
|
+
method: `PUT`,
|
|
1542
|
+
headers: { "Content-Type": `text/plain` }
|
|
1543
|
+
});
|
|
1544
|
+
const httpResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1545
|
+
const httpOffset = httpResponse.headers.get(`Stream-Next-Offset`);
|
|
1546
|
+
expect(httpOffset).toBeDefined();
|
|
1547
|
+
expect(httpOffset).not.toBe(`-1`);
|
|
1548
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1549
|
+
expect(response.status).toBe(200);
|
|
1550
|
+
expect(received).toContain(`event: control`);
|
|
1551
|
+
const controlLine = received.split(`\n`).find((l) => l.startsWith(`data: `) && l.includes(`streamNextOffset`));
|
|
1552
|
+
expect(controlLine).toBeDefined();
|
|
1553
|
+
const controlPayload = controlLine.slice(`data: `.length);
|
|
1554
|
+
const controlData = JSON.parse(controlPayload);
|
|
1555
|
+
expect(controlData[`streamNextOffset`]).toBe(httpOffset);
|
|
1556
|
+
});
|
|
1557
|
+
test(`should send upToDate flag in SSE control events when caught up`, async () => {
|
|
1558
|
+
const streamPath = `/v1/stream/sse-uptodate-test-${Date.now()}`;
|
|
1559
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1560
|
+
method: `PUT`,
|
|
1561
|
+
headers: { "Content-Type": `text/plain` },
|
|
1562
|
+
body: `test data`
|
|
1563
|
+
});
|
|
1564
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `"upToDate"` });
|
|
1565
|
+
expect(response.status).toBe(200);
|
|
1566
|
+
const controlLine = received.split(`\n`).find((l) => l.startsWith(`data: `) && l.includes(`streamNextOffset`));
|
|
1567
|
+
expect(controlLine).toBeDefined();
|
|
1568
|
+
const controlPayload = controlLine.slice(`data: `.length);
|
|
1569
|
+
const controlData = JSON.parse(controlPayload);
|
|
1570
|
+
expect(controlData.upToDate).toBe(true);
|
|
1571
|
+
});
|
|
1572
|
+
test(`should have correct SSE headers (no Content-Length, proper Cache-Control)`, async () => {
|
|
1573
|
+
const streamPath = `/v1/stream/sse-headers-test-${Date.now()}`;
|
|
1574
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1575
|
+
method: `PUT`,
|
|
1576
|
+
headers: { "Content-Type": `text/plain` },
|
|
1577
|
+
body: `test data`
|
|
1578
|
+
});
|
|
1579
|
+
const { response } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `test data` });
|
|
1580
|
+
expect(response.status).toBe(200);
|
|
1581
|
+
expect(response.headers.get(`content-type`)).toBe(`text/event-stream`);
|
|
1582
|
+
expect(response.headers.get(`content-length`)).toBeNull();
|
|
1583
|
+
const cacheControl = response.headers.get(`cache-control`);
|
|
1584
|
+
expect(cacheControl).toContain(`no-cache`);
|
|
1585
|
+
});
|
|
1586
|
+
test(`should handle newlines in text/plain payloads`, async () => {
|
|
1587
|
+
const streamPath = `/v1/stream/sse-newline-test-${Date.now()}`;
|
|
1588
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1589
|
+
method: `PUT`,
|
|
1590
|
+
headers: { "Content-Type": `text/plain` },
|
|
1591
|
+
body: `line1\nline2\nline3`
|
|
1592
|
+
});
|
|
1593
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1594
|
+
expect(response.status).toBe(200);
|
|
1595
|
+
expect(received).toContain(`event: data`);
|
|
1596
|
+
expect(received).toContain(`data: line1`);
|
|
1597
|
+
expect(received).toContain(`data: line2`);
|
|
1598
|
+
expect(received).toContain(`data: line3`);
|
|
1599
|
+
});
|
|
1600
|
+
test(`should generate unique, monotonically increasing offsets in SSE mode`, async () => {
|
|
1601
|
+
const streamPath = `/v1/stream/sse-monotonic-offset-test-${Date.now()}`;
|
|
1602
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1603
|
+
method: `PUT`,
|
|
1604
|
+
headers: { "Content-Type": `text/plain` }
|
|
1605
|
+
});
|
|
1606
|
+
for (let i = 0; i < 5; i++) await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1607
|
+
method: `POST`,
|
|
1608
|
+
headers: { "Content-Type": `text/plain` },
|
|
1609
|
+
body: `message ${i}`
|
|
1610
|
+
});
|
|
1611
|
+
const { response, received } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1612
|
+
expect(response.status).toBe(200);
|
|
1613
|
+
const controlLines = received.split(`\n`).filter((l) => l.startsWith(`data: `) && l.includes(`streamNextOffset`));
|
|
1614
|
+
const offsets = [];
|
|
1615
|
+
for (const line of controlLines) {
|
|
1616
|
+
const payload = line.slice(`data: `.length);
|
|
1617
|
+
const data = JSON.parse(payload);
|
|
1618
|
+
offsets.push(data[`streamNextOffset`]);
|
|
1619
|
+
}
|
|
1620
|
+
for (let i = 1; i < offsets.length; i++) expect(offsets[i] > offsets[i - 1]).toBe(true);
|
|
1621
|
+
});
|
|
1622
|
+
test(`should support reconnection with last known offset`, async () => {
|
|
1623
|
+
const streamPath = `/v1/stream/sse-reconnect-test-${Date.now()}`;
|
|
1624
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1625
|
+
method: `PUT`,
|
|
1626
|
+
headers: { "Content-Type": `text/plain` },
|
|
1627
|
+
body: `message 1`
|
|
1628
|
+
});
|
|
1629
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1630
|
+
method: `POST`,
|
|
1631
|
+
headers: { "Content-Type": `text/plain` },
|
|
1632
|
+
body: `message 2`
|
|
1633
|
+
});
|
|
1634
|
+
let lastOffset = null;
|
|
1635
|
+
const { response: response1, received: received1 } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=-1&live=sse`, { untilContent: `event: control` });
|
|
1636
|
+
expect(response1.status).toBe(200);
|
|
1637
|
+
const controlLine = received1.split(`\n`).find((l) => l.startsWith(`data: `) && l.includes(`streamNextOffset`));
|
|
1638
|
+
const controlPayload = controlLine.slice(`data: `.length);
|
|
1639
|
+
lastOffset = JSON.parse(controlPayload)[`streamNextOffset`];
|
|
1640
|
+
expect(lastOffset).toBeDefined();
|
|
1641
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1642
|
+
method: `POST`,
|
|
1643
|
+
headers: { "Content-Type": `text/plain` },
|
|
1644
|
+
body: `message 3`
|
|
1645
|
+
});
|
|
1646
|
+
const { response: response2, received: received2 } = await fetchSSE(`${getBaseUrl()}${streamPath}?offset=${lastOffset}&live=sse`, { untilContent: `message 3` });
|
|
1647
|
+
expect(response2.status).toBe(200);
|
|
1648
|
+
expect(received2).toContain(`message 3`);
|
|
1649
|
+
expect(received2).not.toContain(`message 1`);
|
|
1650
|
+
expect(received2).not.toContain(`message 2`);
|
|
1651
|
+
});
|
|
1652
|
+
});
|
|
1653
|
+
describe(`JSON Mode`, () => {
|
|
1654
|
+
test(`should allow PUT with empty array body (creates empty stream)`, async () => {
|
|
1655
|
+
const streamPath = `/v1/stream/json-put-empty-array-test-${Date.now()}`;
|
|
1656
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1657
|
+
method: `PUT`,
|
|
1658
|
+
headers: { "Content-Type": `application/json` },
|
|
1659
|
+
body: `[]`
|
|
1660
|
+
});
|
|
1661
|
+
expect(response.status).toBe(201);
|
|
1662
|
+
const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1663
|
+
const data = await readResponse.json();
|
|
1664
|
+
expect(data).toEqual([]);
|
|
1665
|
+
expect(readResponse.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
|
|
1666
|
+
});
|
|
1667
|
+
test(`should reject POST with empty array body`, async () => {
|
|
1668
|
+
const streamPath = `/v1/stream/json-post-empty-array-test-${Date.now()}`;
|
|
1669
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1670
|
+
method: `PUT`,
|
|
1671
|
+
headers: { "Content-Type": `application/json` }
|
|
1672
|
+
});
|
|
1673
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1674
|
+
method: `POST`,
|
|
1675
|
+
headers: { "Content-Type": `application/json` },
|
|
1676
|
+
body: `[]`
|
|
1677
|
+
});
|
|
1678
|
+
expect(response.status).toBe(400);
|
|
1679
|
+
});
|
|
1680
|
+
test(`should handle content-type with charset parameter`, async () => {
|
|
1681
|
+
const streamPath = `/v1/stream/json-charset-test-${Date.now()}`;
|
|
1682
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1683
|
+
method: `PUT`,
|
|
1684
|
+
headers: { "Content-Type": `application/json; charset=utf-8` }
|
|
1685
|
+
});
|
|
1686
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1687
|
+
method: `POST`,
|
|
1688
|
+
headers: { "Content-Type": `application/json; charset=utf-8` },
|
|
1689
|
+
body: JSON.stringify({ message: `hello` })
|
|
1690
|
+
});
|
|
1691
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1692
|
+
const data = await response.json();
|
|
1693
|
+
expect(Array.isArray(data)).toBe(true);
|
|
1694
|
+
expect(data).toEqual([{ message: `hello` }]);
|
|
1695
|
+
});
|
|
1696
|
+
test(`should wrap single JSON value in array`, async () => {
|
|
1697
|
+
const streamPath = `/v1/stream/json-single-test-${Date.now()}`;
|
|
1698
|
+
const stream = await DurableStream.create({
|
|
1699
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1700
|
+
contentType: `application/json`
|
|
1701
|
+
});
|
|
1702
|
+
await stream.append({ message: `hello` });
|
|
1703
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1704
|
+
const data = await response.json();
|
|
1705
|
+
expect(Array.isArray(data)).toBe(true);
|
|
1706
|
+
expect(data).toEqual([{ message: `hello` }]);
|
|
1707
|
+
});
|
|
1708
|
+
test(`should store arrays as single messages`, async () => {
|
|
1709
|
+
const streamPath = `/v1/stream/json-array-test-${Date.now()}`;
|
|
1710
|
+
const stream = await DurableStream.create({
|
|
1711
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1712
|
+
contentType: `application/json`
|
|
1713
|
+
});
|
|
1714
|
+
await stream.append([
|
|
1715
|
+
{ id: 1 },
|
|
1716
|
+
{ id: 2 },
|
|
1717
|
+
{ id: 3 }
|
|
1718
|
+
]);
|
|
1719
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1720
|
+
const data = await response.json();
|
|
1721
|
+
expect(Array.isArray(data)).toBe(true);
|
|
1722
|
+
expect(data).toEqual([[
|
|
1723
|
+
{ id: 1 },
|
|
1724
|
+
{ id: 2 },
|
|
1725
|
+
{ id: 3 }
|
|
1726
|
+
]]);
|
|
1727
|
+
});
|
|
1728
|
+
test(`should concatenate multiple appends into single array`, async () => {
|
|
1729
|
+
const streamPath = `/v1/stream/json-concat-test-${Date.now()}`;
|
|
1730
|
+
const stream = await DurableStream.create({
|
|
1731
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1732
|
+
contentType: `application/json`
|
|
1733
|
+
});
|
|
1734
|
+
await stream.append({ event: `first` });
|
|
1735
|
+
await stream.append({ event: `second` });
|
|
1736
|
+
await stream.append({ event: `third` });
|
|
1737
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1738
|
+
const data = await response.json();
|
|
1739
|
+
expect(Array.isArray(data)).toBe(true);
|
|
1740
|
+
expect(data).toEqual([
|
|
1741
|
+
{ event: `first` },
|
|
1742
|
+
{ event: `second` },
|
|
1743
|
+
{ event: `third` }
|
|
1744
|
+
]);
|
|
1745
|
+
});
|
|
1746
|
+
test(`should handle mixed single values and arrays`, async () => {
|
|
1747
|
+
const streamPath = `/v1/stream/json-mixed-test-${Date.now()}`;
|
|
1748
|
+
const stream = await DurableStream.create({
|
|
1749
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1750
|
+
contentType: `application/json`
|
|
1751
|
+
});
|
|
1752
|
+
await stream.append({ type: `single` });
|
|
1753
|
+
await stream.append([{
|
|
1754
|
+
type: `array`,
|
|
1755
|
+
id: 1
|
|
1756
|
+
}, {
|
|
1757
|
+
type: `array`,
|
|
1758
|
+
id: 2
|
|
1759
|
+
}]);
|
|
1760
|
+
await stream.append({ type: `single-again` });
|
|
1761
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1762
|
+
const data = await response.json();
|
|
1763
|
+
expect(data).toEqual([
|
|
1764
|
+
{ type: `single` },
|
|
1765
|
+
[{
|
|
1766
|
+
type: `array`,
|
|
1767
|
+
id: 1
|
|
1768
|
+
}, {
|
|
1769
|
+
type: `array`,
|
|
1770
|
+
id: 2
|
|
1771
|
+
}],
|
|
1772
|
+
{ type: `single-again` }
|
|
1773
|
+
]);
|
|
1774
|
+
});
|
|
1775
|
+
test(`should reject invalid JSON with 400`, async () => {
|
|
1776
|
+
const streamPath = `/v1/stream/json-invalid-test-${Date.now()}`;
|
|
1777
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1778
|
+
method: `PUT`,
|
|
1779
|
+
headers: { "Content-Type": `application/json` }
|
|
1780
|
+
});
|
|
1781
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1782
|
+
method: `POST`,
|
|
1783
|
+
headers: { "Content-Type": `application/json` },
|
|
1784
|
+
body: `{ invalid json }`
|
|
1785
|
+
});
|
|
1786
|
+
expect(response.status).toBe(400);
|
|
1787
|
+
expect(response.ok).toBe(false);
|
|
1788
|
+
});
|
|
1789
|
+
test(`should handle various JSON value types`, async () => {
|
|
1790
|
+
const streamPath = `/v1/stream/json-types-test-${Date.now()}`;
|
|
1791
|
+
const stream = await DurableStream.create({
|
|
1792
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1793
|
+
contentType: `application/json`
|
|
1794
|
+
});
|
|
1795
|
+
await stream.append(`string value`);
|
|
1796
|
+
await stream.append(42);
|
|
1797
|
+
await stream.append(true);
|
|
1798
|
+
await stream.append(null);
|
|
1799
|
+
await stream.append({ object: `value` });
|
|
1800
|
+
await stream.append([
|
|
1801
|
+
1,
|
|
1802
|
+
2,
|
|
1803
|
+
3
|
|
1804
|
+
]);
|
|
1805
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1806
|
+
const data = await response.json();
|
|
1807
|
+
expect(data).toEqual([
|
|
1808
|
+
`string value`,
|
|
1809
|
+
42,
|
|
1810
|
+
true,
|
|
1811
|
+
null,
|
|
1812
|
+
{ object: `value` },
|
|
1813
|
+
[
|
|
1814
|
+
1,
|
|
1815
|
+
2,
|
|
1816
|
+
3
|
|
1817
|
+
]
|
|
1818
|
+
]);
|
|
1819
|
+
});
|
|
1820
|
+
test(`should preserve JSON structure and nesting`, async () => {
|
|
1821
|
+
const streamPath = `/v1/stream/json-nested-test-${Date.now()}`;
|
|
1822
|
+
const stream = await DurableStream.create({
|
|
1823
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1824
|
+
contentType: `application/json`
|
|
1825
|
+
});
|
|
1826
|
+
await stream.append({
|
|
1827
|
+
user: {
|
|
1828
|
+
id: 123,
|
|
1829
|
+
name: `Alice`,
|
|
1830
|
+
tags: [`admin`, `verified`]
|
|
1831
|
+
},
|
|
1832
|
+
timestamp: `2024-01-01T00:00:00Z`
|
|
1833
|
+
});
|
|
1834
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1835
|
+
const data = await response.json();
|
|
1836
|
+
expect(data).toEqual([{
|
|
1837
|
+
user: {
|
|
1838
|
+
id: 123,
|
|
1839
|
+
name: `Alice`,
|
|
1840
|
+
tags: [`admin`, `verified`]
|
|
1841
|
+
},
|
|
1842
|
+
timestamp: `2024-01-01T00:00:00Z`
|
|
1843
|
+
}]);
|
|
1844
|
+
});
|
|
1845
|
+
test(`should work with client json() iterator`, async () => {
|
|
1846
|
+
const streamPath = `/v1/stream/json-iterator-test-${Date.now()}`;
|
|
1847
|
+
const stream = await DurableStream.create({
|
|
1848
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1849
|
+
contentType: `application/json`
|
|
1850
|
+
});
|
|
1851
|
+
await stream.append({ id: 1 });
|
|
1852
|
+
await stream.append({ id: 2 });
|
|
1853
|
+
await stream.append({ id: 3 });
|
|
1854
|
+
const res = await stream.stream({ live: false });
|
|
1855
|
+
const items = await res.json();
|
|
1856
|
+
expect(items).toEqual([
|
|
1857
|
+
{ id: 1 },
|
|
1858
|
+
{ id: 2 },
|
|
1859
|
+
{ id: 3 }
|
|
1860
|
+
]);
|
|
1861
|
+
});
|
|
1862
|
+
test(`should reject empty JSON arrays with 400`, async () => {
|
|
1863
|
+
const streamPath = `/v1/stream/json-empty-array-test-${Date.now()}`;
|
|
1864
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1865
|
+
method: `PUT`,
|
|
1866
|
+
headers: { "Content-Type": `application/json` }
|
|
1867
|
+
});
|
|
1868
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1869
|
+
method: `POST`,
|
|
1870
|
+
headers: { "Content-Type": `application/json` },
|
|
1871
|
+
body: `[]`
|
|
1872
|
+
});
|
|
1873
|
+
expect(response.status).toBe(400);
|
|
1874
|
+
expect(response.ok).toBe(false);
|
|
1875
|
+
});
|
|
1876
|
+
test(`should store nested arrays as single messages`, async () => {
|
|
1877
|
+
const streamPath = `/v1/stream/json-nested-arrays-test-${Date.now()}`;
|
|
1878
|
+
const stream = await DurableStream.create({
|
|
1879
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1880
|
+
contentType: `application/json`
|
|
1881
|
+
});
|
|
1882
|
+
await stream.append([[1, 2], [3, 4]]);
|
|
1883
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1884
|
+
const data = await response.json();
|
|
1885
|
+
expect(data).toEqual([[[1, 2], [3, 4]]]);
|
|
1886
|
+
});
|
|
1887
|
+
test(`should store arrays as values when double-wrapped`, async () => {
|
|
1888
|
+
const streamPath = `/v1/stream/json-wrapped-array-test-${Date.now()}`;
|
|
1889
|
+
const stream = await DurableStream.create({
|
|
1890
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1891
|
+
contentType: `application/json`
|
|
1892
|
+
});
|
|
1893
|
+
await stream.append([[
|
|
1894
|
+
1,
|
|
1895
|
+
2,
|
|
1896
|
+
3
|
|
1897
|
+
]]);
|
|
1898
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1899
|
+
const data = await response.json();
|
|
1900
|
+
expect(data).toEqual([[[
|
|
1901
|
+
1,
|
|
1902
|
+
2,
|
|
1903
|
+
3
|
|
1904
|
+
]]]);
|
|
1905
|
+
expect(data.length).toBe(1);
|
|
1906
|
+
});
|
|
1907
|
+
test(`should store primitive arrays as single messages`, async () => {
|
|
1908
|
+
const streamPath = `/v1/stream/json-primitive-array-test-${Date.now()}`;
|
|
1909
|
+
const stream = await DurableStream.create({
|
|
1910
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1911
|
+
contentType: `application/json`
|
|
1912
|
+
});
|
|
1913
|
+
await stream.append([
|
|
1914
|
+
1,
|
|
1915
|
+
2,
|
|
1916
|
+
3
|
|
1917
|
+
]);
|
|
1918
|
+
await stream.append([
|
|
1919
|
+
`a`,
|
|
1920
|
+
`b`,
|
|
1921
|
+
`c`
|
|
1922
|
+
]);
|
|
1923
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1924
|
+
const data = await response.json();
|
|
1925
|
+
expect(data).toEqual([[
|
|
1926
|
+
1,
|
|
1927
|
+
2,
|
|
1928
|
+
3
|
|
1929
|
+
], [
|
|
1930
|
+
`a`,
|
|
1931
|
+
`b`,
|
|
1932
|
+
`c`
|
|
1933
|
+
]]);
|
|
1934
|
+
});
|
|
1935
|
+
test(`should handle mixed batching - single values, arrays, and nested arrays`, async () => {
|
|
1936
|
+
const streamPath = `/v1/stream/json-mixed-batching-test-${Date.now()}`;
|
|
1937
|
+
const stream = await DurableStream.create({
|
|
1938
|
+
url: `${getBaseUrl()}${streamPath}`,
|
|
1939
|
+
contentType: `application/json`
|
|
1940
|
+
});
|
|
1941
|
+
await stream.append({ single: 1 });
|
|
1942
|
+
await stream.append([{ batch: 2 }, { batch: 3 }]);
|
|
1943
|
+
await stream.append([[`nested`, `array`]]);
|
|
1944
|
+
await stream.append(42);
|
|
1945
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
1946
|
+
const data = await response.json();
|
|
1947
|
+
expect(data).toEqual([
|
|
1948
|
+
{ single: 1 },
|
|
1949
|
+
[{ batch: 2 }, { batch: 3 }],
|
|
1950
|
+
[[`nested`, `array`]],
|
|
1951
|
+
42
|
|
1952
|
+
]);
|
|
1953
|
+
expect(data.length).toBe(4);
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
describe(`Property-Based Tests (fast-check)`, () => {
|
|
1957
|
+
describe(`Byte-Exactness Property`, () => {
|
|
1958
|
+
test(`arbitrary byte sequences are preserved exactly`, async () => {
|
|
1959
|
+
await fc.assert(fc.asyncProperty(
|
|
1960
|
+
// Generate 1-10 chunks of arbitrary bytes (1-500 bytes each)
|
|
1961
|
+
fc.array(fc.uint8Array({
|
|
1962
|
+
minLength: 1,
|
|
1963
|
+
maxLength: 500
|
|
1964
|
+
}), {
|
|
1965
|
+
minLength: 1,
|
|
1966
|
+
maxLength: 10
|
|
1967
|
+
}),
|
|
1968
|
+
async (chunks) => {
|
|
1969
|
+
const streamPath = `/v1/stream/fc-byte-exactness-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1970
|
+
const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1971
|
+
method: `PUT`,
|
|
1972
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
1973
|
+
});
|
|
1974
|
+
expect([
|
|
1975
|
+
200,
|
|
1976
|
+
201,
|
|
1977
|
+
204
|
|
1978
|
+
]).toContain(createResponse.status);
|
|
1979
|
+
for (const chunk of chunks) {
|
|
1980
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
1981
|
+
method: `POST`,
|
|
1982
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
1983
|
+
body: chunk
|
|
1984
|
+
});
|
|
1985
|
+
expect([200, 204]).toContain(response.status);
|
|
1986
|
+
}
|
|
1987
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
1988
|
+
const expected = new Uint8Array(totalLength);
|
|
1989
|
+
let offset = 0;
|
|
1990
|
+
for (const chunk of chunks) {
|
|
1991
|
+
expected.set(chunk, offset);
|
|
1992
|
+
offset += chunk.length;
|
|
1993
|
+
}
|
|
1994
|
+
const accumulated = [];
|
|
1995
|
+
let currentOffset = null;
|
|
1996
|
+
let iterations = 0;
|
|
1997
|
+
while (iterations < 100) {
|
|
1998
|
+
iterations++;
|
|
1999
|
+
const url = currentOffset ? `${getBaseUrl()}${streamPath}?offset=${encodeURIComponent(currentOffset)}` : `${getBaseUrl()}${streamPath}`;
|
|
2000
|
+
const response = await fetch(url, { method: `GET` });
|
|
2001
|
+
expect(response.status).toBe(200);
|
|
2002
|
+
const buffer = await response.arrayBuffer();
|
|
2003
|
+
const data = new Uint8Array(buffer);
|
|
2004
|
+
if (data.length > 0) accumulated.push(...Array.from(data));
|
|
2005
|
+
const nextOffset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
2006
|
+
const upToDate = response.headers.get(STREAM_UP_TO_DATE_HEADER);
|
|
2007
|
+
if (upToDate === `true` && data.length === 0) break;
|
|
2008
|
+
if (nextOffset === currentOffset) break;
|
|
2009
|
+
currentOffset = nextOffset;
|
|
2010
|
+
}
|
|
2011
|
+
const result = new Uint8Array(accumulated);
|
|
2012
|
+
expect(result.length).toBe(expected.length);
|
|
2013
|
+
for (let i = 0; i < expected.length; i++) expect(result[i]).toBe(expected[i]);
|
|
2014
|
+
return true;
|
|
2015
|
+
}
|
|
2016
|
+
), { numRuns: 20 });
|
|
2017
|
+
});
|
|
2018
|
+
test(`single byte values cover full range (0-255)`, async () => {
|
|
2019
|
+
await fc.assert(fc.asyncProperty(
|
|
2020
|
+
// Generate a byte value from 0-255
|
|
2021
|
+
fc.integer({
|
|
2022
|
+
min: 0,
|
|
2023
|
+
max: 255
|
|
2024
|
+
}),
|
|
2025
|
+
async (byteValue) => {
|
|
2026
|
+
const streamPath = `/v1/stream/fc-single-byte-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2027
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2028
|
+
method: `PUT`,
|
|
2029
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2030
|
+
});
|
|
2031
|
+
const chunk = new Uint8Array([byteValue]);
|
|
2032
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2033
|
+
method: `POST`,
|
|
2034
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2035
|
+
body: chunk
|
|
2036
|
+
});
|
|
2037
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2038
|
+
const buffer = await response.arrayBuffer();
|
|
2039
|
+
const result = new Uint8Array(buffer);
|
|
2040
|
+
expect(result.length).toBe(1);
|
|
2041
|
+
expect(result[0]).toBe(byteValue);
|
|
2042
|
+
return true;
|
|
2043
|
+
}
|
|
2044
|
+
), { numRuns: 50 });
|
|
2045
|
+
});
|
|
2046
|
+
});
|
|
2047
|
+
describe(`Operation Sequence Properties`, () => {
|
|
2048
|
+
test(`random operation sequences maintain stream invariants`, async () => {
|
|
2049
|
+
await fc.assert(fc.asyncProperty(
|
|
2050
|
+
// Generate a sequence of operations
|
|
2051
|
+
fc.array(fc.oneof(
|
|
2052
|
+
// Append operation with random data
|
|
2053
|
+
fc.uint8Array({
|
|
2054
|
+
minLength: 1,
|
|
2055
|
+
maxLength: 200
|
|
2056
|
+
}).map((data) => ({
|
|
2057
|
+
type: `append`,
|
|
2058
|
+
data
|
|
2059
|
+
})),
|
|
2060
|
+
// Full read operation
|
|
2061
|
+
fc.constant({ type: `read` }),
|
|
2062
|
+
// Read from a saved offset (index into saved offsets array)
|
|
2063
|
+
fc.integer({
|
|
2064
|
+
min: 0,
|
|
2065
|
+
max: 20
|
|
2066
|
+
}).map((idx) => ({
|
|
2067
|
+
type: `readFromOffset`,
|
|
2068
|
+
offsetIndex: idx
|
|
2069
|
+
}))
|
|
2070
|
+
), {
|
|
2071
|
+
minLength: 5,
|
|
2072
|
+
maxLength: 30
|
|
2073
|
+
}),
|
|
2074
|
+
async (operations) => {
|
|
2075
|
+
const streamPath = `/v1/stream/fc-ops-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2076
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2077
|
+
method: `PUT`,
|
|
2078
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2079
|
+
});
|
|
2080
|
+
const appendedData = [];
|
|
2081
|
+
const savedOffsets = [];
|
|
2082
|
+
for (const op of operations) if (op.type === `append`) {
|
|
2083
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2084
|
+
method: `POST`,
|
|
2085
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2086
|
+
body: op.data
|
|
2087
|
+
});
|
|
2088
|
+
expect([200, 204]).toContain(response.status);
|
|
2089
|
+
appendedData.push(...Array.from(op.data));
|
|
2090
|
+
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
2091
|
+
if (offset) savedOffsets.push(offset);
|
|
2092
|
+
} else if (op.type === `read`) {
|
|
2093
|
+
const accumulated = [];
|
|
2094
|
+
let currentOffset = null;
|
|
2095
|
+
let iterations = 0;
|
|
2096
|
+
while (iterations < 100) {
|
|
2097
|
+
iterations++;
|
|
2098
|
+
const url = currentOffset ? `${getBaseUrl()}${streamPath}?offset=${encodeURIComponent(currentOffset)}` : `${getBaseUrl()}${streamPath}`;
|
|
2099
|
+
const response = await fetch(url, { method: `GET` });
|
|
2100
|
+
const buffer = await response.arrayBuffer();
|
|
2101
|
+
const data = new Uint8Array(buffer);
|
|
2102
|
+
if (data.length > 0) accumulated.push(...Array.from(data));
|
|
2103
|
+
const nextOffset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
2104
|
+
const upToDate = response.headers.get(STREAM_UP_TO_DATE_HEADER);
|
|
2105
|
+
if (upToDate === `true` && data.length === 0) break;
|
|
2106
|
+
if (nextOffset === currentOffset) break;
|
|
2107
|
+
currentOffset = nextOffset;
|
|
2108
|
+
}
|
|
2109
|
+
expect(accumulated.length).toBe(appendedData.length);
|
|
2110
|
+
for (let i = 0; i < appendedData.length; i++) expect(accumulated[i]).toBe(appendedData[i]);
|
|
2111
|
+
} else {
|
|
2112
|
+
if (savedOffsets.length === 0) continue;
|
|
2113
|
+
const offsetIdx = op.offsetIndex % savedOffsets.length;
|
|
2114
|
+
const offset = savedOffsets[offsetIdx];
|
|
2115
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=${encodeURIComponent(offset)}`, { method: `GET` });
|
|
2116
|
+
expect(response.status).toBe(200);
|
|
2117
|
+
const nextOffset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
2118
|
+
if (nextOffset) expect(nextOffset >= offset).toBe(true);
|
|
2119
|
+
}
|
|
2120
|
+
return true;
|
|
2121
|
+
}
|
|
2122
|
+
), { numRuns: 15 });
|
|
2123
|
+
});
|
|
2124
|
+
test(`offsets are always monotonically increasing`, async () => {
|
|
2125
|
+
await fc.assert(fc.asyncProperty(
|
|
2126
|
+
// Generate multiple chunks to append
|
|
2127
|
+
fc.array(fc.uint8Array({
|
|
2128
|
+
minLength: 1,
|
|
2129
|
+
maxLength: 100
|
|
2130
|
+
}), {
|
|
2131
|
+
minLength: 2,
|
|
2132
|
+
maxLength: 15
|
|
2133
|
+
}),
|
|
2134
|
+
async (chunks) => {
|
|
2135
|
+
const streamPath = `/v1/stream/fc-monotonic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2136
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2137
|
+
method: `PUT`,
|
|
2138
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2139
|
+
});
|
|
2140
|
+
const offsets = [];
|
|
2141
|
+
for (const chunk of chunks) {
|
|
2142
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2143
|
+
method: `POST`,
|
|
2144
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2145
|
+
body: chunk
|
|
2146
|
+
});
|
|
2147
|
+
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
2148
|
+
expect(offset).toBeDefined();
|
|
2149
|
+
offsets.push(offset);
|
|
2150
|
+
}
|
|
2151
|
+
for (let i = 1; i < offsets.length; i++) expect(offsets[i] > offsets[i - 1]).toBe(true);
|
|
2152
|
+
return true;
|
|
2153
|
+
}
|
|
2154
|
+
), { numRuns: 25 });
|
|
2155
|
+
});
|
|
2156
|
+
test(`read-your-writes: data is immediately visible after append`, async () => {
|
|
2157
|
+
await fc.assert(fc.asyncProperty(fc.uint8Array({
|
|
2158
|
+
minLength: 1,
|
|
2159
|
+
maxLength: 500
|
|
2160
|
+
}), async (data) => {
|
|
2161
|
+
const streamPath = `/v1/stream/fc-ryw-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2162
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2163
|
+
method: `PUT`,
|
|
2164
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2165
|
+
});
|
|
2166
|
+
const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2167
|
+
method: `POST`,
|
|
2168
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2169
|
+
body: data
|
|
2170
|
+
});
|
|
2171
|
+
expect([200, 204]).toContain(appendResponse.status);
|
|
2172
|
+
const readResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2173
|
+
expect(readResponse.status).toBe(200);
|
|
2174
|
+
const buffer = await readResponse.arrayBuffer();
|
|
2175
|
+
const result = new Uint8Array(buffer);
|
|
2176
|
+
expect(result.length).toBe(data.length);
|
|
2177
|
+
for (let i = 0; i < data.length; i++) expect(result[i]).toBe(data[i]);
|
|
2178
|
+
return true;
|
|
2179
|
+
}), { numRuns: 30 });
|
|
2180
|
+
});
|
|
2181
|
+
});
|
|
2182
|
+
describe(`Immutability Properties`, () => {
|
|
2183
|
+
test(`data at offset never changes after additional appends`, async () => {
|
|
2184
|
+
await fc.assert(fc.asyncProperty(
|
|
2185
|
+
// Initial data and additional data to append
|
|
2186
|
+
fc.uint8Array({
|
|
2187
|
+
minLength: 1,
|
|
2188
|
+
maxLength: 200
|
|
2189
|
+
}),
|
|
2190
|
+
fc.array(fc.uint8Array({
|
|
2191
|
+
minLength: 1,
|
|
2192
|
+
maxLength: 100
|
|
2193
|
+
}), {
|
|
2194
|
+
minLength: 1,
|
|
2195
|
+
maxLength: 5
|
|
2196
|
+
}),
|
|
2197
|
+
async (initialData, additionalChunks) => {
|
|
2198
|
+
const streamPath = `/v1/stream/fc-immutable-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2199
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2200
|
+
method: `PUT`,
|
|
2201
|
+
headers: { "Content-Type": `application/octet-stream` }
|
|
2202
|
+
});
|
|
2203
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2204
|
+
method: `POST`,
|
|
2205
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2206
|
+
body: initialData
|
|
2207
|
+
});
|
|
2208
|
+
const initialRead = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2209
|
+
const initialBuffer = await initialRead.arrayBuffer();
|
|
2210
|
+
const initialResult = new Uint8Array(initialBuffer);
|
|
2211
|
+
for (const chunk of additionalChunks) await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2212
|
+
method: `POST`,
|
|
2213
|
+
headers: { "Content-Type": `application/octet-stream` },
|
|
2214
|
+
body: chunk
|
|
2215
|
+
});
|
|
2216
|
+
const rereadResponse = await fetch(`${getBaseUrl()}${streamPath}`);
|
|
2217
|
+
const rereadBuffer = await rereadResponse.arrayBuffer();
|
|
2218
|
+
const rereadResult = new Uint8Array(rereadBuffer);
|
|
2219
|
+
expect(rereadResult.length).toBeGreaterThanOrEqual(initialResult.length);
|
|
2220
|
+
for (let i = 0; i < initialResult.length; i++) expect(rereadResult[i]).toBe(initialResult[i]);
|
|
2221
|
+
return true;
|
|
2222
|
+
}
|
|
2223
|
+
), { numRuns: 20 });
|
|
2224
|
+
});
|
|
2225
|
+
});
|
|
2226
|
+
describe(`Offset Validation Properties`, () => {
|
|
2227
|
+
test(`should reject offsets with invalid characters`, async () => {
|
|
2228
|
+
await fc.assert(fc.asyncProperty(
|
|
2229
|
+
// Generate strings with at least one invalid character
|
|
2230
|
+
fc.oneof(
|
|
2231
|
+
// Strings with spaces
|
|
2232
|
+
fc.tuple(fc.string(), fc.string()).map(([a, b]) => `${a} ${b}`),
|
|
2233
|
+
// Strings with path traversal
|
|
2234
|
+
fc.string().map((s) => `../${s}`),
|
|
2235
|
+
fc.string().map((s) => `${s}/..`),
|
|
2236
|
+
// Strings with null bytes
|
|
2237
|
+
fc.string().map((s) => `${s}\u0000`),
|
|
2238
|
+
// Strings with newlines
|
|
2239
|
+
fc.string().map((s) => `${s}\n`),
|
|
2240
|
+
fc.string().map((s) => `${s}\r\n`),
|
|
2241
|
+
// Strings with commas
|
|
2242
|
+
fc.tuple(fc.string(), fc.string()).map(([a, b]) => `${a},${b}`),
|
|
2243
|
+
// Strings with slashes
|
|
2244
|
+
fc.tuple(fc.string(), fc.string()).map(([a, b]) => `${a}/${b}`)
|
|
2245
|
+
),
|
|
2246
|
+
async (badOffset) => {
|
|
2247
|
+
const streamPath = `/v1/stream/fc-bad-offset-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2248
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2249
|
+
method: `PUT`,
|
|
2250
|
+
headers: { "Content-Type": `text/plain` },
|
|
2251
|
+
body: `test`
|
|
2252
|
+
});
|
|
2253
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}?offset=${encodeURIComponent(badOffset)}`, { method: `GET` });
|
|
2254
|
+
expect(response.status).toBe(400);
|
|
2255
|
+
return true;
|
|
2256
|
+
}
|
|
2257
|
+
), { numRuns: 30 });
|
|
2258
|
+
});
|
|
2259
|
+
});
|
|
2260
|
+
describe(`Sequence Ordering Properties`, () => {
|
|
2261
|
+
test(`lexicographically ordered seq values are accepted`, async () => {
|
|
2262
|
+
await fc.assert(fc.asyncProperty(
|
|
2263
|
+
// Generate a sorted array of unique lexicographic strings
|
|
2264
|
+
fc.array(fc.stringMatching(/^[0-9a-zA-Z]+$/), {
|
|
2265
|
+
minLength: 2,
|
|
2266
|
+
maxLength: 10
|
|
2267
|
+
}).map((arr) => [...new Set(arr)].sort()).filter((arr) => arr.length >= 2),
|
|
2268
|
+
async (seqValues) => {
|
|
2269
|
+
const streamPath = `/v1/stream/fc-seq-order-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2270
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2271
|
+
method: `PUT`,
|
|
2272
|
+
headers: { "Content-Type": `text/plain` }
|
|
2273
|
+
});
|
|
2274
|
+
for (const seq of seqValues) {
|
|
2275
|
+
const response = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2276
|
+
method: `POST`,
|
|
2277
|
+
headers: {
|
|
2278
|
+
"Content-Type": `text/plain`,
|
|
2279
|
+
[STREAM_SEQ_HEADER]: seq
|
|
2280
|
+
},
|
|
2281
|
+
body: `data-${seq}`
|
|
2282
|
+
});
|
|
2283
|
+
expect([200, 204]).toContain(response.status);
|
|
2284
|
+
}
|
|
2285
|
+
return true;
|
|
2286
|
+
}
|
|
2287
|
+
), { numRuns: 20 });
|
|
2288
|
+
});
|
|
2289
|
+
test(`out-of-order seq values are rejected`, async () => {
|
|
2290
|
+
await fc.assert(fc.asyncProperty(
|
|
2291
|
+
// Generate two strings where the first is lexicographically greater
|
|
2292
|
+
fc.tuple(fc.stringMatching(/^[0-9a-zA-Z]+$/), fc.stringMatching(/^[0-9a-zA-Z]+$/)).filter(([a, b]) => a > b && a.length > 0 && b.length > 0),
|
|
2293
|
+
async ([firstSeq, secondSeq]) => {
|
|
2294
|
+
const streamPath = `/v1/stream/fc-seq-reject-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
2295
|
+
await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2296
|
+
method: `PUT`,
|
|
2297
|
+
headers: { "Content-Type": `text/plain` }
|
|
2298
|
+
});
|
|
2299
|
+
const response1 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2300
|
+
method: `POST`,
|
|
2301
|
+
headers: {
|
|
2302
|
+
"Content-Type": `text/plain`,
|
|
2303
|
+
[STREAM_SEQ_HEADER]: firstSeq
|
|
2304
|
+
},
|
|
2305
|
+
body: `first`
|
|
2306
|
+
});
|
|
2307
|
+
expect([200, 204]).toContain(response1.status);
|
|
2308
|
+
const response2 = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2309
|
+
method: `POST`,
|
|
2310
|
+
headers: {
|
|
2311
|
+
"Content-Type": `text/plain`,
|
|
2312
|
+
[STREAM_SEQ_HEADER]: secondSeq
|
|
2313
|
+
},
|
|
2314
|
+
body: `second`
|
|
2315
|
+
});
|
|
2316
|
+
expect(response2.status).toBe(409);
|
|
2317
|
+
return true;
|
|
2318
|
+
}
|
|
2319
|
+
), { numRuns: 25 });
|
|
2320
|
+
});
|
|
2321
|
+
});
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
//#endregion
|
|
2326
|
+
export { runConformanceTests };
|