queen-mq 0.16.0 → 1.0.3

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.
Files changed (44) hide show
  1. package/README.md +175 -0
  2. package/client-v2/Queen.js +125 -15
  3. package/client-v2/README.md +69 -0
  4. package/client-v2/builders/QueueBuilder.js +18 -20
  5. package/client-v2/builders/TimerBuilder.js +262 -0
  6. package/client-v2/builders/TransactionBuilder.js +199 -10
  7. package/client-v2/consumer/ConsumerManager.js +64 -12
  8. package/client-v2/http/HttpClient.js +382 -32
  9. package/client-v2/kv/Kv.js +432 -0
  10. package/client-v2/kv/expiry.js +148 -0
  11. package/client-v2/streams/runtime/Runner.js +45 -0
  12. package/client-v2/utils/defaults.js +20 -1
  13. package/package.json +12 -3
  14. package/test-v2/_kvtimers.js +71 -0
  15. package/test-v2/ackwindow.js +265 -0
  16. package/test-v2/auth.js +65 -149
  17. package/test-v2/docs.js +204 -0
  18. package/test-v2/http-unit/hostHeader.test.js +411 -0
  19. package/test-v2/http-unit/retry429.test.js +319 -0
  20. package/test-v2/kv-unit/_planServer.js +65 -0
  21. package/test-v2/kv-unit/kvWire.test.js +377 -0
  22. package/test-v2/kv-unit/timerWire.test.js +177 -0
  23. package/test-v2/kv-unit/txnWire.test.js +222 -0
  24. package/test-v2/kv.js +273 -0
  25. package/test-v2/load.js +37 -41
  26. package/test-v2/maintenance.js +2 -2
  27. package/test-v2/push.js +25 -35
  28. package/test-v2/run.js +67 -5
  29. package/test-v2/semantics.js +801 -0
  30. package/test-v2/stream/_helpers.js +8 -1
  31. package/test-v2/stream/combined.js +4 -3
  32. package/test-v2/stream/cron.js +1 -1
  33. package/test-v2/stream/eventTime.js +8 -5
  34. package/test-v2/stream/operators.js +5 -5
  35. package/test-v2/stream/recovery.js +4 -1
  36. package/test-v2/stream/session.js +3 -3
  37. package/test-v2/stream/sliding.js +1 -1
  38. package/test-v2/stream/throughput.js +3 -2
  39. package/test-v2/stream/tumbling.js +16 -12
  40. package/test-v2/streams-unit/ack.test.js +203 -0
  41. package/test-v2/streams-unit/e2e.test.js +4 -1
  42. package/test-v2/timers.js +209 -0
  43. package/test-v2/transaction.js +4 -1
  44. package/test-v2/watermark.js +78 -62
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The tests behind the published examples.
3
+ *
4
+ * Every marked region in this file is rendered on queenmq.com: the homepage,
5
+ * the quickstart, the concept pages and the HTTP reference pull these exact
6
+ * lines through webdoc/scripts/gen-snippets.mjs. They are tests written to
7
+ * read as documentation: real queue names, a partition key that means
8
+ * something, and no test scaffolding inside a marked region. Assertions stay
9
+ * outside the markers.
10
+ *
11
+ * After editing a marked region, regenerate the partials with
12
+ * `pnpm --dir webdoc gen` or the docs CI check fails on drift. The queues used
13
+ * here (orders, payments, invoices) are wiped by cleanupTestData in run.js
14
+ * before every suite run, exactly like the test-% queues.
15
+ */
16
+
17
+ export async function docsProduceAndConsume(client) {
18
+ // docs:start(js-push)
19
+ const res = await client
20
+ .queue('orders')
21
+ .partition('customer-42')
22
+ .push([{ data: { orderId: 9137, amount: 99.5 } }])
23
+ // docs:end
24
+ if (res[0].status !== 'queued') {
25
+ return { success: false, message: `Push not queued: ${JSON.stringify(res[0])}` }
26
+ }
27
+
28
+ // The consume loop acks each message when the callback resolves, so when
29
+ // the awaited chain returns, the billing group's cursor has moved.
30
+ // docs:start(js-consume)
31
+ await client
32
+ .queue('orders')
33
+ .group('billing')
34
+ .subscriptionMode('all')
35
+ .limit(1)
36
+ .each()
37
+ .consume(async (message) => {
38
+ console.log(message.data)
39
+ })
40
+ // docs:end
41
+
42
+ const drained = await client
43
+ .queue('orders')
44
+ .group('billing')
45
+ .batch(1)
46
+ .wait(false)
47
+ .pop()
48
+ if (drained.length !== 0) {
49
+ return { success: false, message: 'Billing group cursor did not advance after consume' }
50
+ }
51
+
52
+ // A raw pop on another cursor still sees the message: groups are fan-out.
53
+ // docs:start(js-pop)
54
+ const messages = await client
55
+ .queue('orders')
56
+ .batch(10)
57
+ .wait(true)
58
+ .pop()
59
+ // docs:end
60
+ if (messages.length !== 1 || messages[0].data.orderId !== 9137) {
61
+ return { success: false, message: `Fan-out pop expected the order back, got ${JSON.stringify(messages)}` }
62
+ }
63
+
64
+ return { success: true, message: 'Push, consume and fan-out pop all verified' }
65
+ }
66
+
67
+ export async function docsDeduplication(client) {
68
+ // The fixed transactionId below survives reruns because cleanupTestData
69
+ // purges log_txns for these queues before the suite starts.
70
+ // docs:start(js-push-dedup)
71
+ const first = await client
72
+ .queue('payments')
73
+ .partition('customer-42')
74
+ .push([{ transactionId: 'order-9137-paid', data: { orderId: 9137, amount: 99.5 } }])
75
+
76
+ const retry = await client
77
+ .queue('payments')
78
+ .partition('customer-42')
79
+ .push([{ transactionId: 'order-9137-paid', data: { orderId: 9137, amount: 99.5 } }])
80
+ // retry[0].status is 'duplicate': the second push wrote nothing
81
+ // and answers with the first message's id.
82
+ // docs:end
83
+ if (first[0].status !== 'queued') {
84
+ return { success: false, message: `First push not queued: ${JSON.stringify(first[0])}` }
85
+ }
86
+ if (retry[0].status !== 'duplicate') {
87
+ return { success: false, message: `Retry not deduplicated: ${JSON.stringify(retry[0])}` }
88
+ }
89
+ if (retry[0].message_id !== first[0].message_id) {
90
+ return { success: false, message: 'Duplicate did not answer with the original message id' }
91
+ }
92
+ return { success: true, message: 'Second push with the same transactionId wrote nothing' }
93
+ }
94
+
95
+ export async function docsReplayAndSeek(client) {
96
+ const seeded = await client.queue('orders').partition('customer-91').push([
97
+ { data: { orderId: 5001, amount: 12 } },
98
+ { data: { orderId: 5002, amount: 24 } },
99
+ { data: { orderId: 5003, amount: 36 } },
100
+ ])
101
+ if (!seeded.every(r => r.status === 'queued')) {
102
+ return { success: false, message: 'Seed pushes failed' }
103
+ }
104
+
105
+ // docs:start(js-replay)
106
+ // A fresh group with subscriptionMode('all') reads the whole retained
107
+ // lane from the beginning, without touching any other group's cursor.
108
+ const replayed = await client
109
+ .queue('orders')
110
+ .partition('customer-91')
111
+ .group('audit')
112
+ .subscriptionMode('all')
113
+ .batch(100)
114
+ .wait(true)
115
+ .pop()
116
+ // docs:end
117
+ const ids = replayed.map(m => m.data.orderId)
118
+ if (ids.length !== 3 || ids[0] !== 5001 || ids[1] !== 5002 || ids[2] !== 5003) {
119
+ return { success: false, message: `Replay expected [5001,5002,5003] in order, got ${JSON.stringify(ids)}` }
120
+ }
121
+
122
+ // docs:start(js-seek)
123
+ // Move the audit group's cursor back one hour. The seek also releases
124
+ // any live lease, so an in-flight batch is abandoned, not acked.
125
+ await client.admin.seekConsumerGroup('audit', 'orders', {
126
+ timestamp: new Date(Date.now() - 3600 * 1000).toISOString(),
127
+ })
128
+ // docs:end
129
+
130
+ const again = await client
131
+ .queue('orders')
132
+ .partition('customer-91')
133
+ .group('audit')
134
+ .batch(100)
135
+ .wait(true)
136
+ .pop()
137
+ const againIds = again.map(m => m.data.orderId)
138
+ if (againIds.length !== 3 || againIds[0] !== 5001) {
139
+ return { success: false, message: `Post-seek pop expected the lane again, got ${JSON.stringify(againIds)}` }
140
+ }
141
+ return { success: true, message: 'Replay from the beginning and seek-back both verified' }
142
+ }
143
+
144
+ export async function docsTransactionalHandoff(client) {
145
+ const seeded = await client
146
+ .queue('orders')
147
+ .partition('customer-77')
148
+ .push([{ data: { orderId: 4102, amount: 18 } }])
149
+ if (seeded[0].status !== 'queued') {
150
+ return { success: false, message: 'Seed push failed' }
151
+ }
152
+
153
+ // docs:start(js-transaction)
154
+ await client
155
+ .queue('orders')
156
+ .group('invoicing')
157
+ .subscriptionMode('all')
158
+ .each()
159
+ .autoAck(false) // the acknowledgement belongs to the transaction, not to the loop
160
+ .limit(1)
161
+ .idleMillis(5000)
162
+ .consume(async (message) => {
163
+ // commit() throws when the broker rejects the bundle, so reaching the
164
+ // line after it means the ack and the push are both durable.
165
+ await client
166
+ .transaction()
167
+ .queue('invoices')
168
+ .push([{ data: { orderId: message.data.orderId, invoiced: true } }])
169
+ .ack(message, 'completed', { consumerGroup: 'invoicing' })
170
+ .commit()
171
+ })
172
+ // docs:end
173
+
174
+ // The loop takes whichever order is oldest for this group, and other docs
175
+ // tests push to the same queue, so identity is checked against the orders
176
+ // that actually exist rather than against a hardcoded id. A throwaway group
177
+ // reads them without disturbing the invoicing cursor.
178
+ const everyOrder = await client
179
+ .queue('orders')
180
+ .group(`docs-audit-${Date.now()}`)
181
+ .subscriptionMode('all')
182
+ .batch(50)
183
+ .partitions(20)
184
+ .wait(false)
185
+ .pop()
186
+ const orderIds = everyOrder.map(m => m.data.orderId)
187
+
188
+ const invoiced = await client
189
+ .queue('invoices')
190
+ .batch(1)
191
+ .partitions(10)
192
+ .wait(true)
193
+ .pop()
194
+ if (invoiced.length !== 1 || !orderIds.includes(invoiced[0].data.orderId)) {
195
+ return {
196
+ success: false,
197
+ message: `Invoice not committed for a real order: ${JSON.stringify(invoiced)} against ${JSON.stringify(orderIds)}`,
198
+ }
199
+ }
200
+ if (invoiced[0].data.invoiced !== true) {
201
+ return { success: false, message: 'Invoice payload corrupted' }
202
+ }
203
+ return { success: true, message: 'Ack and next-stage push committed together' }
204
+ }
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Host-routed proxy support: selecting a tenant cluster at a queen_proxy
3
+ * deployment (PLAN_QUEEN_PROXY_CLOUD.md §2 -- the proxy resolves the cluster
4
+ * from the Host header's first DNS label, proxy/src/cache.rs
5
+ * slug_from_host).
6
+ *
7
+ * The defect these tests lock down: `fetch()` silently drops a caller-supplied
8
+ * `Host` header (WHATWG forbidden header name), so a JS user pointing at a
9
+ * shared address either got 421 cluster_unknown or -- worse, on a cell with a
10
+ * default cluster -- was routed to somebody ELSE's cluster with no signal.
11
+ * `hostHeader` advertises the cluster host while the socket stays pinned to the
12
+ * configured address, and a `headers: { Host }` is mapped onto it out loud.
13
+ *
14
+ * Same style as retry429.test.js: real node:http servers, no mocking framework.
15
+ * Every assertion here is about what actually arrives on the wire (req.headers.host).
16
+ */
17
+
18
+ import { describe, it } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import { createServer } from 'node:http'
21
+
22
+ import { Queen } from '../../client-v2/index.js'
23
+ import { HttpClient } from '../../client-v2/http/HttpClient.js'
24
+ import { LoadBalancer } from '../../client-v2/http/LoadBalancer.js'
25
+
26
+ /**
27
+ * Spin up a real HTTP server that records the Host header (and the rest) of
28
+ * every request it receives, then answers with `respond(hit)`.
29
+ */
30
+ function withHostServer(run, respond = () => ({ status: 200, body: { ok: true } })) {
31
+ const hits = []
32
+ const server = createServer((req, res) => {
33
+ let raw = ''
34
+ req.on('data', chunk => { raw += chunk })
35
+ req.on('end', () => {
36
+ const hit = { method: req.method, url: req.url, host: req.headers.host, headers: req.headers }
37
+ hits.push(hit)
38
+ const descriptor = respond(hit)
39
+ res.writeHead(descriptor.status ?? 200, { 'Content-Type': 'application/json' })
40
+ res.end(JSON.stringify(descriptor.body ?? { ok: true }))
41
+ })
42
+ })
43
+
44
+ return new Promise((resolve, reject) => {
45
+ server.listen(0, '127.0.0.1', () => {
46
+ const { port } = server.address()
47
+ const teardown = () => new Promise(r => server.close(r))
48
+ Promise.resolve()
49
+ .then(() => run({ url: `http://127.0.0.1:${port}`, port, hits }))
50
+ .then(
51
+ async (value) => { await teardown(); resolve(value) },
52
+ async (err) => { await teardown(); reject(err) }
53
+ )
54
+ })
55
+ })
56
+ }
57
+
58
+ /** Capture console.warn for the duration of `fn` (the trap warning is not
59
+ * routed through the QUEEN_CLIENT_LOG-gated logger on purpose). */
60
+ async function captureWarnings(fn) {
61
+ const original = console.warn
62
+ const warnings = []
63
+ console.warn = (...args) => { warnings.push(args.join(' ')) }
64
+ try {
65
+ return { value: await fn(), warnings }
66
+ } finally {
67
+ console.warn = original
68
+ }
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // The wire: what Host actually goes out.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe('HttpClient — hostHeader on the wire', () => {
76
+ it('sends the address as Host when no hostHeader is configured (unchanged default)', async () => {
77
+ await withHostServer(async ({ url, port, hits }) => {
78
+ const client = new HttpClient({ baseUrl: url })
79
+ try {
80
+ await client.get('/x')
81
+ assert.equal(hits.length, 1)
82
+ assert.equal(hits[0].host, `127.0.0.1:${port}`)
83
+ } finally {
84
+ await client.destroy()
85
+ }
86
+ })
87
+ })
88
+
89
+ it('sends the configured hostHeader verbatim while still connecting to baseUrl', async () => {
90
+ await withHostServer(async ({ url, hits }) => {
91
+ const client = new HttpClient({ baseUrl: url, hostHeader: 'acme.eu1.queenmq.cloud' })
92
+ try {
93
+ const result = await client.get('/x')
94
+ assert.deepEqual(result, { ok: true }, 'the request must still reach the configured address')
95
+ assert.equal(hits[0].host, 'acme.eu1.queenmq.cloud')
96
+ } finally {
97
+ await client.destroy()
98
+ }
99
+ })
100
+ })
101
+
102
+ it('advertises a bare slug (what queen_proxy turns into a cluster) without appending the real port', async () => {
103
+ await withHostServer(async ({ url, hits }) => {
104
+ const client = new HttpClient({ baseUrl: url, hostHeader: 'sdka' })
105
+ try {
106
+ await client.get('/x')
107
+ assert.equal(hits[0].host, 'sdka', 'slug_from_host takes the first label — no :port noise')
108
+ } finally {
109
+ await client.destroy()
110
+ }
111
+ })
112
+ })
113
+
114
+ it('keeps an explicit port in the advertised Host, and never resolves that name in DNS', async () => {
115
+ await withHostServer(async ({ url, hits }) => {
116
+ // `acme.invalid:6711` cannot resolve and 6711 is not our listener: the
117
+ // request can only arrive if the socket is pinned to baseUrl.
118
+ const client = new HttpClient({ baseUrl: url, hostHeader: 'acme.invalid:6711' })
119
+ try {
120
+ await client.get('/x')
121
+ assert.equal(hits[0].host, 'acme.invalid:6711')
122
+ } finally {
123
+ await client.destroy()
124
+ }
125
+ })
126
+ })
127
+
128
+ it('applies to every verb and keeps the path/body intact', async () => {
129
+ await withHostServer(async ({ url, hits }) => {
130
+ const client = new HttpClient({ baseUrl: url, hostHeader: 'acme' })
131
+ try {
132
+ await client.post('/api/v1/push', { items: [1] })
133
+ await client.delete('/api/v1/queues/q1')
134
+ assert.deepEqual(hits.map(h => h.host), ['acme', 'acme'])
135
+ assert.deepEqual(hits.map(h => h.url), ['/api/v1/push', '/api/v1/queues/q1'])
136
+ } finally {
137
+ await client.destroy()
138
+ }
139
+ })
140
+ })
141
+
142
+ it('shares one pinned agent across concurrent first requests', async () => {
143
+ await withHostServer(async ({ url, hits }) => {
144
+ const client = new HttpClient({ baseUrl: url, hostHeader: 'acme' })
145
+ try {
146
+ // All five race the (async) agent creation; each must still get the
147
+ // advertised Host, and destroy() must be able to release every socket.
148
+ const results = await Promise.all(Array.from({ length: 5 }, () => client.get('/x')))
149
+ assert.equal(results.length, 5)
150
+ assert.equal(hits.length, 5)
151
+ for (const hit of hits) assert.equal(hit.host, 'acme')
152
+ } finally {
153
+ await client.destroy()
154
+ }
155
+ })
156
+ })
157
+
158
+ it('leaves other custom headers (and the bearer token) untouched', async () => {
159
+ await withHostServer(async ({ url, hits }) => {
160
+ const client = new HttpClient({
161
+ baseUrl: url,
162
+ bearerToken: 'key-a',
163
+ hostHeader: 'acme',
164
+ headers: { 'X-Trace': 'abc' }
165
+ })
166
+ try {
167
+ await client.get('/x')
168
+ assert.equal(hits[0].host, 'acme')
169
+ assert.equal(hits[0].headers['x-trace'], 'abc')
170
+ assert.equal(hits[0].headers['authorization'], 'Bearer key-a')
171
+ } finally {
172
+ await client.destroy()
173
+ }
174
+ })
175
+ })
176
+ })
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // The trap: `headers: { Host }` must never be silently dropped.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe('HttpClient — headers: { Host } is mapped, never silently ignored', () => {
183
+ it('maps headers.Host onto hostHeader and warns on the console once', async () => {
184
+ await withHostServer(async ({ url, hits }) => {
185
+ const { warnings } = await captureWarnings(async () => {
186
+ // Distinct value per test: the warn-once dedupe is keyed by value.
187
+ const client = new HttpClient({ baseUrl: url, headers: { Host: 'mapped-a.example.test' } })
188
+ try {
189
+ await client.get('/x')
190
+ } finally {
191
+ await client.destroy()
192
+ }
193
+ })
194
+ assert.equal(hits[0].host, 'mapped-a.example.test', 'the Host the caller asked for must reach the wire')
195
+ assert.equal(warnings.length, 1, 'exactly one warning for the first occurrence')
196
+ assert.match(warnings[0], /forbidden header name/)
197
+ assert.match(warnings[0], /hostHeader/)
198
+ })
199
+ })
200
+
201
+ it('warns only once for a repeated identical misconfiguration', async () => {
202
+ await withHostServer(async ({ url }) => {
203
+ const { warnings } = await captureWarnings(async () => {
204
+ for (let i = 0; i < 3; i++) {
205
+ const client = new HttpClient({ baseUrl: url, headers: { Host: 'mapped-b.example.test' } })
206
+ await client.get('/x')
207
+ await client.destroy()
208
+ }
209
+ })
210
+ assert.equal(warnings.length, 1, 'one warning per distinct offending value, not per client')
211
+ })
212
+ })
213
+
214
+ it('accepts a lower-case `host` key too (header names are case-insensitive)', async () => {
215
+ await withHostServer(async ({ url, hits }) => {
216
+ await captureWarnings(async () => {
217
+ const client = new HttpClient({ baseUrl: url, headers: { host: 'mapped-c.example.test' } })
218
+ try {
219
+ await client.get('/x')
220
+ } finally {
221
+ await client.destroy()
222
+ }
223
+ })
224
+ assert.equal(hits[0].host, 'mapped-c.example.test')
225
+ })
226
+ })
227
+
228
+ it('lets an explicit hostHeader win over a conflicting headers.Host, and says so', async () => {
229
+ await withHostServer(async ({ url, hits }) => {
230
+ const { warnings } = await captureWarnings(async () => {
231
+ const client = new HttpClient({
232
+ baseUrl: url,
233
+ hostHeader: 'explicit-d.example.test',
234
+ headers: { Host: 'conflicting-d.example.test' }
235
+ })
236
+ try {
237
+ await client.get('/x')
238
+ } finally {
239
+ await client.destroy()
240
+ }
241
+ })
242
+ assert.equal(hits[0].host, 'explicit-d.example.test')
243
+ assert.equal(warnings.length, 1)
244
+ assert.match(warnings[0], /is ignored because hostHeader/)
245
+ })
246
+ })
247
+
248
+ it('does not warn when headers.Host merely repeats hostHeader', async () => {
249
+ await withHostServer(async ({ url, hits }) => {
250
+ const { warnings } = await captureWarnings(async () => {
251
+ const client = new HttpClient({
252
+ baseUrl: url,
253
+ hostHeader: 'same-e.example.test',
254
+ headers: { Host: 'same-e.example.test' }
255
+ })
256
+ try {
257
+ await client.get('/x')
258
+ } finally {
259
+ await client.destroy()
260
+ }
261
+ })
262
+ assert.equal(hits[0].host, 'same-e.example.test')
263
+ assert.equal(warnings.length, 0)
264
+ })
265
+ })
266
+ })
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Validation + failure modes: a bad or unhonorable Host must fail loudly.
270
+ // ---------------------------------------------------------------------------
271
+
272
+ describe('HttpClient — hostHeader validation', () => {
273
+ it('rejects a URL-shaped hostHeader at construction', () => {
274
+ assert.throws(
275
+ () => new HttpClient({ baseUrl: 'http://127.0.0.1:1', hostHeader: 'http://acme.example.test' }),
276
+ /bare authority/
277
+ )
278
+ })
279
+
280
+ it('rejects a hostHeader with a path or whitespace', () => {
281
+ assert.throws(() => new HttpClient({ baseUrl: 'http://127.0.0.1:1', hostHeader: 'acme/foo' }), /bare authority/)
282
+ assert.throws(() => new HttpClient({ baseUrl: 'http://127.0.0.1:1', hostHeader: 'acme host' }), /bare authority/)
283
+ })
284
+
285
+ it('rejects an empty hostHeader', () => {
286
+ assert.throws(() => new HttpClient({ baseUrl: 'http://127.0.0.1:1', hostHeader: ' ' }), /non-empty string/)
287
+ })
288
+
289
+ it('treats hostHeader: null as "no override" (default behaviour)', async () => {
290
+ await withHostServer(async ({ url, port, hits }) => {
291
+ const client = new HttpClient({ baseUrl: url, hostHeader: null })
292
+ try {
293
+ await client.get('/x')
294
+ assert.equal(hits[0].host, `127.0.0.1:${port}`)
295
+ } finally {
296
+ await client.destroy()
297
+ }
298
+ })
299
+ })
300
+
301
+ it('refuses to send (instead of leaking the address as Host) once the client is destroyed', async () => {
302
+ await withHostServer(async ({ url, hits }) => {
303
+ const client = new HttpClient({ baseUrl: url, hostHeader: 'acme', retryAttempts: 1 })
304
+ await client.get('/x')
305
+ await client.destroy()
306
+ await assert.rejects(client.get('/x'), /cannot be honored|Refusing to send/)
307
+ assert.equal(hits.length, 1, 'the post-destroy request must not reach the server with the wrong Host')
308
+ })
309
+ })
310
+ })
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Load balancing: one virtual Host, several real backends.
314
+ // ---------------------------------------------------------------------------
315
+
316
+ describe('HttpClient — hostHeader with multiple backends', () => {
317
+ it('advertises the same Host to every backend while dialing each one', async () => {
318
+ await withHostServer(async ({ url: urlA, hits: hitsA }) => {
319
+ await withHostServer(async ({ url: urlB, hits: hitsB }) => {
320
+ const loadBalancer = new LoadBalancer([urlA, urlB], 'round-robin')
321
+ const client = new HttpClient({ loadBalancer, hostHeader: 'acme.eu1.queenmq.cloud' })
322
+ try {
323
+ for (let i = 0; i < 4; i++) await client.get('/x')
324
+ assert.ok(hitsA.length > 0 && hitsB.length > 0, `both backends must be dialed (${hitsA.length}/${hitsB.length})`)
325
+ for (const hit of [...hitsA, ...hitsB]) {
326
+ assert.equal(hit.host, 'acme.eu1.queenmq.cloud')
327
+ }
328
+ } finally {
329
+ await client.destroy()
330
+ }
331
+ })
332
+ })
333
+ })
334
+
335
+ it('keeps failover working: a 500 on one backend still lands on the other with the right Host', async () => {
336
+ await withHostServer(
337
+ async ({ url: urlA, hits: hitsA }) => {
338
+ await withHostServer(async ({ url: urlB, hits: hitsB }) => {
339
+ const loadBalancer = new LoadBalancer([urlA, urlB], 'round-robin')
340
+ const client = new HttpClient({ loadBalancer, hostHeader: 'acme', retryAttempts: 1 })
341
+ try {
342
+ const result = await client.get('/x')
343
+ assert.deepEqual(result, { ok: true })
344
+ assert.ok(hitsA.length > 0, 'the failing backend was tried')
345
+ assert.ok(hitsB.length > 0, 'and the request failed over to the healthy one')
346
+ for (const hit of [...hitsA, ...hitsB]) assert.equal(hit.host, 'acme')
347
+ } finally {
348
+ await client.destroy()
349
+ }
350
+ })
351
+ },
352
+ () => ({ status: 500, body: { error: 'boom' } })
353
+ )
354
+ })
355
+ })
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Queen public API wiring.
359
+ // ---------------------------------------------------------------------------
360
+
361
+ describe('Queen — hostHeader config plumbing', () => {
362
+ it('push() through a Queen configured with hostHeader carries it on the wire', async () => {
363
+ await withHostServer(
364
+ async ({ url, hits }) => {
365
+ const queen = new Queen({ url, hostHeader: 'sdkb', bearerToken: 'key-b', handleSignals: false })
366
+ try {
367
+ const result = await queen.queue('q1').push({ hello: 'world' })
368
+ assert.equal(result[0].status, 'queued')
369
+ assert.equal(hits[0].host, 'sdkb')
370
+ assert.equal(hits[0].headers['authorization'], 'Bearer key-b')
371
+ } finally {
372
+ await queen.close()
373
+ }
374
+ },
375
+ () => ({ status: 200, body: [{ status: 'queued', transactionId: 'tx-1' }] })
376
+ )
377
+ })
378
+
379
+ it('a Queen built with headers: { Host } routes to that cluster (Go/Python parity)', async () => {
380
+ await withHostServer(
381
+ async ({ url, hits }) => {
382
+ const { value: result } = await captureWarnings(async () => {
383
+ const queen = new Queen({ url, headers: { Host: 'parity-f.example.test' }, handleSignals: false })
384
+ try {
385
+ return await queen.queue('q1').push({ hello: 'world' })
386
+ } finally {
387
+ await queen.close()
388
+ }
389
+ })
390
+ assert.equal(result[0].status, 'queued')
391
+ assert.equal(hits[0].host, 'parity-f.example.test')
392
+ },
393
+ () => ({ status: 200, body: [{ status: 'queued', transactionId: 'tx-1' }] })
394
+ )
395
+ })
396
+
397
+ it('a Queen without hostHeader is byte-for-byte unchanged (Host = the address)', async () => {
398
+ await withHostServer(
399
+ async ({ url, port, hits }) => {
400
+ const queen = new Queen({ url, handleSignals: false })
401
+ try {
402
+ await queen.queue('q1').push({ hello: 'world' })
403
+ assert.equal(hits[0].host, `127.0.0.1:${port}`)
404
+ } finally {
405
+ await queen.close()
406
+ }
407
+ },
408
+ () => ({ status: 200, body: [{ status: 'queued', transactionId: 'tx-1' }] })
409
+ )
410
+ })
411
+ })