@blokjs/trigger-pubsub 0.6.18 → 0.6.19
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/dist/PubSubTrigger.js +20 -1
- package/package.json +5 -4
- package/CHANGELOG.md +0 -22
- package/__tests__/integration/gcp-pubsub.real-emulator.test.ts +0 -235
- package/__tests__/integration/kafka-pubsub.real-kafka.test.ts +0 -269
- package/__tests__/integration/nats-pubsub.real-nats.test.ts +0 -138
- package/src/PubSubTrigger.test.ts +0 -151
- package/src/PubSubTrigger.ts +0 -402
- package/src/adapters/AWSSNSAdapter.ts +0 -322
- package/src/adapters/AzureServiceBusAdapter.ts +0 -263
- package/src/adapters/GCPPubSubAdapter.ts +0 -236
- package/src/adapters/KafkaPubSubAdapter.ts +0 -194
- package/src/adapters/NATSPubSubAdapter.ts +0 -326
- package/src/adapters/RedisStreamsPubSubAdapter.ts +0 -225
- package/src/adapters/factory.test.ts +0 -87
- package/src/adapters/factory.ts +0 -88
- package/src/adapters/new-adapters.test.ts +0 -108
- package/src/index.ts +0 -67
- package/template/.env.example +0 -8
- package/template/package.json +0 -44
- package/template/src/Nodes.ts +0 -10
- package/template/src/Workflows.ts +0 -8
- package/template/src/index.ts +0 -41
- package/template/src/runner/PubSubServer.ts +0 -39
- package/template/src/runner/types/Workflows.ts +0 -7
- package/template/src/workflows/messages/on-message.ts +0 -48
- package/template/tsconfig.json +0 -31
- package/template/vitest.config.ts +0 -39
- package/tsconfig.json +0 -32
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import type { PubSubMessage } from "@blokjs/runner";
|
|
2
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
3
|
-
import { KafkaPubSubAdapter } from "../../src/adapters/KafkaPubSubAdapter";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Narrow shapes for the bits of `kafkajs` we touch in this test. The
|
|
7
|
-
* runtime types in the package are loosely-typed (the adapter itself
|
|
8
|
-
* pins `any` behind biome-ignore comments); these test-local interfaces
|
|
9
|
-
* keep us on the safe `as unknown as <T>` boundary-cast path required
|
|
10
|
-
* by the repo's no-`any`-in-tests rule.
|
|
11
|
-
*/
|
|
12
|
-
interface KafkaAdminClient {
|
|
13
|
-
connect(): Promise<void>;
|
|
14
|
-
disconnect(): Promise<void>;
|
|
15
|
-
createTopics(opts: {
|
|
16
|
-
waitForLeaders?: boolean;
|
|
17
|
-
topics: Array<{ topic: string; numPartitions: number; replicationFactor: number }>;
|
|
18
|
-
}): Promise<boolean>;
|
|
19
|
-
fetchTopicMetadata(opts: { topics: string[] }): Promise<unknown>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface KafkaConsumerForWarmup {
|
|
23
|
-
connect(): Promise<void>;
|
|
24
|
-
subscribe(opts: { topic: string; fromBeginning?: boolean }): Promise<void>;
|
|
25
|
-
run(opts: { eachMessage: () => Promise<void> }): Promise<void>;
|
|
26
|
-
disconnect(): Promise<void>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface KafkaClient {
|
|
30
|
-
admin(): KafkaAdminClient;
|
|
31
|
-
consumer(opts: { groupId: string }): KafkaConsumerForWarmup;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface KafkaJsModule {
|
|
35
|
-
Kafka: new (opts: { clientId: string; brokers: string[] }) => KafkaClient;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Real-Kafka integration test for `KafkaPubSubAdapter` (closes Phase 2.1
|
|
40
|
-
* broker-adapter test debt deferred from PR #91).
|
|
41
|
-
*
|
|
42
|
-
* Exercises both the **fan-out** (no `consumerGroup` — adapter generates
|
|
43
|
-
* per-subscriber group ids) and **competing-consumer** (explicit shared
|
|
44
|
-
* `consumerGroup`) delivery patterns documented in the adapter.
|
|
45
|
-
*
|
|
46
|
-
* Gated on `BLOK_INTEGRATION_KAFKA_BROKERS`. Skipped when unset, so the
|
|
47
|
-
* regular unit test run on a developer laptop without docker-compose
|
|
48
|
-
* doesn't break.
|
|
49
|
-
*
|
|
50
|
-
* Bring up the test fixtures via:
|
|
51
|
-
* docker compose -f infra/testing/docker-compose.yml up -d kafka
|
|
52
|
-
*
|
|
53
|
-
* Then run:
|
|
54
|
-
* BLOK_INTEGRATION_KAFKA_BROKERS=localhost:9094 bun run test
|
|
55
|
-
*/
|
|
56
|
-
|
|
57
|
-
const KAFKA_BROKERS = process.env.BLOK_INTEGRATION_KAFKA_BROKERS;
|
|
58
|
-
const d = KAFKA_BROKERS ? describe : describe.skip;
|
|
59
|
-
|
|
60
|
-
// CI runners are slower than local — Kafka group coordinator assignment
|
|
61
|
-
// alone takes a few seconds for a fresh consumer, and the first message
|
|
62
|
-
// after subscribe usually waits one heartbeat. Bump the per-test cap so
|
|
63
|
-
// CI doesn't flake on the assignment + first-poll round-trip.
|
|
64
|
-
const TEST_TIMEOUT_MS = 45_000;
|
|
65
|
-
const SUBSCRIPTION_WARMUP_MS = 2_000;
|
|
66
|
-
|
|
67
|
-
d("KafkaPubSubAdapter — real Kafka", () => {
|
|
68
|
-
let producer: KafkaPubSubAdapter;
|
|
69
|
-
let consumerA: KafkaPubSubAdapter;
|
|
70
|
-
let consumerB: KafkaPubSubAdapter;
|
|
71
|
-
let admin: KafkaAdminClient | null = null;
|
|
72
|
-
|
|
73
|
-
beforeAll(async () => {
|
|
74
|
-
const brokers = KAFKA_BROKERS?.split(",").map((s) => s.trim()) ?? [];
|
|
75
|
-
producer = new KafkaPubSubAdapter({ brokers, clientId: "blok-test-pubsub-producer" });
|
|
76
|
-
await producer.connect();
|
|
77
|
-
|
|
78
|
-
consumerA = new KafkaPubSubAdapter({ brokers, clientId: "blok-test-pubsub-consumer-a" });
|
|
79
|
-
await consumerA.connect();
|
|
80
|
-
|
|
81
|
-
consumerB = new KafkaPubSubAdapter({ brokers, clientId: "blok-test-pubsub-consumer-b" });
|
|
82
|
-
await consumerB.connect();
|
|
83
|
-
|
|
84
|
-
// Admin client used only by this test to pre-create topics
|
|
85
|
-
// before consumers subscribe — eliminates the metadata-propagation
|
|
86
|
-
// race where `auto.create.topics.enable` lets a subscribe succeed
|
|
87
|
-
// against a topic the producer can't immediately see (kafkajs
|
|
88
|
-
// reports "This server does not host this topic-partition" on the
|
|
89
|
-
// first publish attempt). The adapter itself relies on auto-create
|
|
90
|
-
// in production (Kafka's durable-log model means orphan publishes
|
|
91
|
-
// are valid); this guard is purely a test-hygiene measure.
|
|
92
|
-
const kafkajs = (await import("kafkajs")) as unknown as KafkaJsModule;
|
|
93
|
-
const adminKafka = new kafkajs.Kafka({ clientId: "blok-test-pubsub-admin", brokers });
|
|
94
|
-
admin = adminKafka.admin();
|
|
95
|
-
await admin.connect();
|
|
96
|
-
|
|
97
|
-
// Cold-start warmup. On a freshly booted broker (the CI case),
|
|
98
|
-
// Kafka's `__consumer_offsets` topic and the group-coordinator
|
|
99
|
-
// election aren't ready immediately after the broker accepts
|
|
100
|
-
// admin connections. The first `consumer.run()` that triggers
|
|
101
|
-
// `findCoordinator` hits the 5-retry budget before the offsets
|
|
102
|
-
// topic is fully initialised and throws
|
|
103
|
-
// `KafkaJSNumberOfRetriesExceeded: This is not the correct
|
|
104
|
-
// coordinator for this group`.
|
|
105
|
-
//
|
|
106
|
-
// Drive that initialisation explicitly here: create a throwaway
|
|
107
|
-
// topic, run a brief consumer against it (which forces group-
|
|
108
|
-
// coordinator election), then disconnect. Subsequent real test
|
|
109
|
-
// subscribes find a warm coordinator and skip the race.
|
|
110
|
-
const warmupTopic = `blok-test-pubsub-warmup-${Math.random().toString(36).slice(2)}`;
|
|
111
|
-
await admin.createTopics({
|
|
112
|
-
waitForLeaders: true,
|
|
113
|
-
topics: [{ topic: warmupTopic, numPartitions: 1, replicationFactor: 1 }],
|
|
114
|
-
});
|
|
115
|
-
const warmupConsumer = adminKafka.consumer({ groupId: `blok-test-warmup-${Math.random().toString(36).slice(2)}` });
|
|
116
|
-
await warmupConsumer.connect();
|
|
117
|
-
await warmupConsumer.subscribe({ topic: warmupTopic, fromBeginning: true });
|
|
118
|
-
await warmupConsumer.run({
|
|
119
|
-
eachMessage: async () => {
|
|
120
|
-
/* never fires — topic stays empty */
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
// Give the group coordinator a beat to finalise the join+sync
|
|
124
|
-
// for the warmup group. Without this, the next test's subscribe
|
|
125
|
-
// can still race on a brand-new group's coordinator lookup.
|
|
126
|
-
await new Promise((r) => setTimeout(r, 2_000));
|
|
127
|
-
await warmupConsumer.disconnect();
|
|
128
|
-
}, TEST_TIMEOUT_MS);
|
|
129
|
-
|
|
130
|
-
afterAll(async () => {
|
|
131
|
-
// Best-effort cleanup. A failed test can leave a consumer in a
|
|
132
|
-
// state where `disconnect()` hangs (kafkajs internal retry loop
|
|
133
|
-
// still running). Wrap each call in a 5s timeout so a single
|
|
134
|
-
// stuck consumer doesn't trip vitest's default 10s afterAll
|
|
135
|
-
// cap and cascade-mask the real failure.
|
|
136
|
-
const safeDisconnect = async (label: string, fn: () => Promise<void>): Promise<void> => {
|
|
137
|
-
try {
|
|
138
|
-
await Promise.race([
|
|
139
|
-
fn(),
|
|
140
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} disconnect timed out`)), 5_000)),
|
|
141
|
-
]);
|
|
142
|
-
} catch {
|
|
143
|
-
/* ignore — afterAll is best-effort */
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
await safeDisconnect("consumerA", () => consumerA.disconnect());
|
|
147
|
-
await safeDisconnect("consumerB", () => consumerB.disconnect());
|
|
148
|
-
await safeDisconnect("producer", () => producer.disconnect());
|
|
149
|
-
if (admin) {
|
|
150
|
-
await safeDisconnect("admin", () => admin?.disconnect() ?? Promise.resolve());
|
|
151
|
-
}
|
|
152
|
-
}, 30_000);
|
|
153
|
-
|
|
154
|
-
async function createTopic(topic: string, numPartitions = 1): Promise<void> {
|
|
155
|
-
if (!admin) throw new Error("admin client not initialised — beforeAll didn't run");
|
|
156
|
-
await admin.createTopics({
|
|
157
|
-
waitForLeaders: true,
|
|
158
|
-
topics: [{ topic, numPartitions, replicationFactor: 1 }],
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
it(
|
|
163
|
-
"fan-out: every subscriber without an explicit consumerGroup receives every message",
|
|
164
|
-
async () => {
|
|
165
|
-
// Topic name varies per run so the consumer-group offset state
|
|
166
|
-
// from a prior run doesn't bleed into this one. Pre-create via
|
|
167
|
-
// admin so the producer's metadata is fresh before the first
|
|
168
|
-
// publish — bypasses the auto-create-on-publish race that
|
|
169
|
-
// shows up as "This server does not host this topic-partition".
|
|
170
|
-
const topic = `blok-test-pubsub-fanout-${Math.random().toString(36).slice(2)}`;
|
|
171
|
-
await createTopic(topic, 1);
|
|
172
|
-
const receivedA: PubSubMessage[] = [];
|
|
173
|
-
const receivedB: PubSubMessage[] = [];
|
|
174
|
-
|
|
175
|
-
await consumerA.subscribe({ topic, durable: false, startFrom: "earliest" }, async (msg) => {
|
|
176
|
-
receivedA.push(msg);
|
|
177
|
-
});
|
|
178
|
-
await consumerB.subscribe({ topic, durable: false, startFrom: "earliest" }, async (msg) => {
|
|
179
|
-
receivedB.push(msg);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
// Kafka group-coordinator assignment is asynchronous; the
|
|
183
|
-
// `consumer.run` call above returns once the consumer LOOP has
|
|
184
|
-
// started, but the first heartbeat-driven assignment isn't done
|
|
185
|
-
// yet. Without this warm-up we routinely lose the first publish
|
|
186
|
-
// to "no assigned partition" on cold-CI runs.
|
|
187
|
-
await new Promise((r) => setTimeout(r, SUBSCRIPTION_WARMUP_MS));
|
|
188
|
-
|
|
189
|
-
await producer.publish(topic, { hello: "kafka", n: 1 });
|
|
190
|
-
|
|
191
|
-
await waitFor(() => receivedA.length === 1 && receivedB.length === 1, TEST_TIMEOUT_MS - 10_000);
|
|
192
|
-
|
|
193
|
-
expect(receivedA[0].body).toEqual({ hello: "kafka", n: 1 });
|
|
194
|
-
expect(receivedB[0].body).toEqual({ hello: "kafka", n: 1 });
|
|
195
|
-
expect(receivedA[0].topic).toBe(topic);
|
|
196
|
-
expect(receivedB[0].topic).toBe(topic);
|
|
197
|
-
},
|
|
198
|
-
TEST_TIMEOUT_MS,
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
it(
|
|
202
|
-
"competing-consumer: explicit consumerGroup means each message goes to exactly one subscriber",
|
|
203
|
-
async () => {
|
|
204
|
-
// Three partitions so each consumer in the shared group gets at
|
|
205
|
-
// least one assignment — exercises the actual competing-consumer
|
|
206
|
-
// load-balance instead of routing all 10 messages to whichever
|
|
207
|
-
// consumer happened to win the single-partition leadership.
|
|
208
|
-
const topic = `blok-test-pubsub-competing-${Math.random().toString(36).slice(2)}`;
|
|
209
|
-
await createTopic(topic, 3);
|
|
210
|
-
const group = `blok-test-workers-${Math.random().toString(36).slice(2)}`;
|
|
211
|
-
const receivedA: PubSubMessage[] = [];
|
|
212
|
-
const receivedB: PubSubMessage[] = [];
|
|
213
|
-
|
|
214
|
-
await consumerA.subscribe({ topic, consumerGroup: group, durable: false, startFrom: "earliest" }, async (msg) => {
|
|
215
|
-
receivedA.push(msg);
|
|
216
|
-
});
|
|
217
|
-
await consumerB.subscribe({ topic, consumerGroup: group, durable: false, startFrom: "earliest" }, async (msg) => {
|
|
218
|
-
receivedB.push(msg);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
await new Promise((r) => setTimeout(r, SUBSCRIPTION_WARMUP_MS));
|
|
222
|
-
|
|
223
|
-
// Publish 10 messages with distinct partition keys so the
|
|
224
|
-
// broker spreads them across the topic's partitions. The
|
|
225
|
-
// auto-created topic defaults to 1 partition, which means BOTH
|
|
226
|
-
// consumers join the same group but only ONE gets the partition
|
|
227
|
-
// assignment — and therefore all 10 messages. We can't assert a
|
|
228
|
-
// 50/50 split, but we CAN assert exactly-once delivery across
|
|
229
|
-
// the union.
|
|
230
|
-
for (let i = 0; i < 10; i++) {
|
|
231
|
-
await producer.publish(topic, { n: i }, { partitionKey: String(i) });
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
await waitFor(() => receivedA.length + receivedB.length === 10, TEST_TIMEOUT_MS - 10_000);
|
|
235
|
-
|
|
236
|
-
expect(receivedA.length + receivedB.length).toBe(10);
|
|
237
|
-
const seenN = new Set<number>();
|
|
238
|
-
for (const m of [...receivedA, ...receivedB]) {
|
|
239
|
-
const n = (m.body as { n: number }).n;
|
|
240
|
-
expect(seenN.has(n)).toBe(false);
|
|
241
|
-
seenN.add(n);
|
|
242
|
-
}
|
|
243
|
-
expect(seenN.size).toBe(10);
|
|
244
|
-
},
|
|
245
|
-
TEST_TIMEOUT_MS,
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
it(
|
|
249
|
-
"publish to a topic with no subscribers does not throw",
|
|
250
|
-
async () => {
|
|
251
|
-
// Note: kafkajs's auto.create.topics.enable is true on the test
|
|
252
|
-
// broker, so this also exercises the on-publish topic-create
|
|
253
|
-
// path. The publish should succeed even if no consumer is
|
|
254
|
-
// listening — that's the durable-log model Kafka guarantees.
|
|
255
|
-
const topic = `blok-test-pubsub-orphan-${Math.random().toString(36).slice(2)}`;
|
|
256
|
-
await expect(producer.publish(topic, { dropped: true })).resolves.toBeUndefined();
|
|
257
|
-
},
|
|
258
|
-
TEST_TIMEOUT_MS,
|
|
259
|
-
);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
263
|
-
const start = Date.now();
|
|
264
|
-
while (Date.now() - start < timeoutMs) {
|
|
265
|
-
if (predicate()) return;
|
|
266
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
267
|
-
}
|
|
268
|
-
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
|
269
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import type { PubSubMessage } from "@blokjs/runner";
|
|
2
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
3
|
-
import { NATSPubSubAdapter } from "../../src/adapters/NATSPubSubAdapter";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Real-NATS integration test for `NATSPubSubAdapter` (closes the
|
|
7
|
-
* integration test debt from PR #87).
|
|
8
|
-
*
|
|
9
|
-
* Exercises both the **fan-out** (no `consumerGroup`) and
|
|
10
|
-
* **competing-consumer** (with `consumerGroup`) delivery patterns
|
|
11
|
-
* documented in the trigger.
|
|
12
|
-
*
|
|
13
|
-
* Gated on `BLOK_INTEGRATION_NATS_SERVERS`. Skipped when unset.
|
|
14
|
-
*
|
|
15
|
-
* Bring up the test fixtures via:
|
|
16
|
-
* docker compose -f infra/testing/docker-compose.yml up -d nats
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const NATS_SERVERS = process.env.BLOK_INTEGRATION_NATS_SERVERS;
|
|
20
|
-
const d = NATS_SERVERS ? describe : describe.skip;
|
|
21
|
-
|
|
22
|
-
// CI runners are slower than local — JetStream stream + consumer creation
|
|
23
|
-
// + cross-instance subscription propagation can take several seconds on
|
|
24
|
-
// a cold container. Override the default 5s timeout so CI doesn't flake.
|
|
25
|
-
const TEST_TIMEOUT_MS = 30_000;
|
|
26
|
-
const SUBSCRIPTION_WARMUP_MS = 500;
|
|
27
|
-
|
|
28
|
-
d("NATSPubSubAdapter — real NATS", () => {
|
|
29
|
-
let producer: NATSPubSubAdapter;
|
|
30
|
-
let consumerA: NATSPubSubAdapter;
|
|
31
|
-
let consumerB: NATSPubSubAdapter;
|
|
32
|
-
|
|
33
|
-
beforeAll(async () => {
|
|
34
|
-
producer = new NATSPubSubAdapter({
|
|
35
|
-
servers: NATS_SERVERS?.split(",").map((s) => s.trim()) ?? [],
|
|
36
|
-
});
|
|
37
|
-
await producer.connect();
|
|
38
|
-
|
|
39
|
-
consumerA = new NATSPubSubAdapter({
|
|
40
|
-
servers: NATS_SERVERS?.split(",").map((s) => s.trim()) ?? [],
|
|
41
|
-
});
|
|
42
|
-
await consumerA.connect();
|
|
43
|
-
|
|
44
|
-
consumerB = new NATSPubSubAdapter({
|
|
45
|
-
servers: NATS_SERVERS?.split(",").map((s) => s.trim()) ?? [],
|
|
46
|
-
});
|
|
47
|
-
await consumerB.connect();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterAll(async () => {
|
|
51
|
-
await consumerA.disconnect();
|
|
52
|
-
await consumerB.disconnect();
|
|
53
|
-
await producer.disconnect();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it(
|
|
57
|
-
"fan-out: every subscriber without consumerGroup receives every message",
|
|
58
|
-
async () => {
|
|
59
|
-
const topic = `blok-test-pubsub-fanout-${Math.random().toString(36).slice(2)}`;
|
|
60
|
-
const receivedA: PubSubMessage[] = [];
|
|
61
|
-
const receivedB: PubSubMessage[] = [];
|
|
62
|
-
|
|
63
|
-
await consumerA.subscribe({ topic, durable: false }, async (msg) => {
|
|
64
|
-
receivedA.push(msg);
|
|
65
|
-
});
|
|
66
|
-
await consumerB.subscribe({ topic, durable: false }, async (msg) => {
|
|
67
|
-
receivedB.push(msg);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// JetStream subscription registration is asynchronous — give the
|
|
71
|
-
// consumer a moment to install before publishing.
|
|
72
|
-
await new Promise((r) => setTimeout(r, SUBSCRIPTION_WARMUP_MS));
|
|
73
|
-
|
|
74
|
-
await producer.publish(topic, { hello: "world", n: 1 });
|
|
75
|
-
|
|
76
|
-
await waitFor(() => receivedA.length === 1 && receivedB.length === 1, TEST_TIMEOUT_MS - 5_000);
|
|
77
|
-
|
|
78
|
-
expect(receivedA[0].body).toEqual({ hello: "world", n: 1 });
|
|
79
|
-
expect(receivedB[0].body).toEqual({ hello: "world", n: 1 });
|
|
80
|
-
},
|
|
81
|
-
TEST_TIMEOUT_MS,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
it(
|
|
85
|
-
"competing-consumer: consumerGroup means each message goes to one subscriber",
|
|
86
|
-
async () => {
|
|
87
|
-
const topic = `blok-test-pubsub-competing-${Math.random().toString(36).slice(2)}`;
|
|
88
|
-
const group = "workers";
|
|
89
|
-
const receivedA: PubSubMessage[] = [];
|
|
90
|
-
const receivedB: PubSubMessage[] = [];
|
|
91
|
-
|
|
92
|
-
await consumerA.subscribe({ topic, consumerGroup: group, durable: false }, async (msg) => {
|
|
93
|
-
receivedA.push(msg);
|
|
94
|
-
});
|
|
95
|
-
await consumerB.subscribe({ topic, consumerGroup: group, durable: false }, async (msg) => {
|
|
96
|
-
receivedB.push(msg);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
await new Promise((r) => setTimeout(r, SUBSCRIPTION_WARMUP_MS));
|
|
100
|
-
|
|
101
|
-
// Publish 10 messages — should split across A + B (not exact 50/50
|
|
102
|
-
// but each message reaches exactly one).
|
|
103
|
-
for (let i = 0; i < 10; i++) {
|
|
104
|
-
await producer.publish(topic, { n: i });
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
await waitFor(() => receivedA.length + receivedB.length === 10, TEST_TIMEOUT_MS - 5_000);
|
|
108
|
-
|
|
109
|
-
expect(receivedA.length + receivedB.length).toBe(10);
|
|
110
|
-
// No duplicate keys across both lists.
|
|
111
|
-
const seenN = new Set<number>();
|
|
112
|
-
for (const m of [...receivedA, ...receivedB]) {
|
|
113
|
-
const n = (m.body as { n: number }).n;
|
|
114
|
-
expect(seenN.has(n)).toBe(false);
|
|
115
|
-
seenN.add(n);
|
|
116
|
-
}
|
|
117
|
-
},
|
|
118
|
-
TEST_TIMEOUT_MS,
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
it(
|
|
122
|
-
"publish to an unsubscribed topic does not throw",
|
|
123
|
-
async () => {
|
|
124
|
-
const topic = `blok-test-pubsub-orphan-${Math.random().toString(36).slice(2)}`;
|
|
125
|
-
await expect(producer.publish(topic, { dropped: true })).resolves.toBeUndefined();
|
|
126
|
-
},
|
|
127
|
-
TEST_TIMEOUT_MS,
|
|
128
|
-
);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
132
|
-
const start = Date.now();
|
|
133
|
-
while (Date.now() - start < timeoutMs) {
|
|
134
|
-
if (predicate()) return;
|
|
135
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
136
|
-
}
|
|
137
|
-
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
|
138
|
-
}
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PubSubTrigger Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests the PubSubTrigger base class and adapter interfaces.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { describe, expect, it, vi } from "vitest";
|
|
8
|
-
|
|
9
|
-
describe("PubSubTrigger", () => {
|
|
10
|
-
describe("PubSubMessage Interface", () => {
|
|
11
|
-
it("should accept valid pub/sub message structure", () => {
|
|
12
|
-
const message = {
|
|
13
|
-
id: "msg-123",
|
|
14
|
-
body: { event: "order.created", data: { orderId: 456 } },
|
|
15
|
-
attributes: { "content-type": "application/json", source: "orders-service" },
|
|
16
|
-
raw: {},
|
|
17
|
-
topic: "orders",
|
|
18
|
-
subscription: "orders-subscription",
|
|
19
|
-
publishTime: new Date(),
|
|
20
|
-
ack: async () => {},
|
|
21
|
-
nack: async () => {},
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
expect(message.id).toBe("msg-123");
|
|
25
|
-
expect(message.body).toEqual({ event: "order.created", data: { orderId: 456 } });
|
|
26
|
-
expect(message.topic).toBe("orders");
|
|
27
|
-
expect(message.subscription).toBe("orders-subscription");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("should handle minimal required fields", () => {
|
|
31
|
-
const message = {
|
|
32
|
-
id: "msg-id",
|
|
33
|
-
body: null,
|
|
34
|
-
attributes: {},
|
|
35
|
-
raw: null,
|
|
36
|
-
topic: "test-topic",
|
|
37
|
-
ack: async () => {},
|
|
38
|
-
nack: async () => {},
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
expect(message.id).toBeDefined();
|
|
42
|
-
expect(message.topic).toBeDefined();
|
|
43
|
-
expect(message.ack).toBeDefined();
|
|
44
|
-
expect(message.nack).toBeDefined();
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe("PubSubAdapter Interface", () => {
|
|
49
|
-
it("should validate adapter interface methods", () => {
|
|
50
|
-
const mockAdapter = {
|
|
51
|
-
provider: "gcp" as const,
|
|
52
|
-
connect: vi.fn().mockResolvedValue(undefined),
|
|
53
|
-
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
54
|
-
subscribe: vi.fn().mockResolvedValue(undefined),
|
|
55
|
-
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
56
|
-
isConnected: vi.fn().mockReturnValue(true),
|
|
57
|
-
healthCheck: vi.fn().mockResolvedValue(true),
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
expect(mockAdapter.provider).toBe("gcp");
|
|
61
|
-
expect(typeof mockAdapter.connect).toBe("function");
|
|
62
|
-
expect(typeof mockAdapter.disconnect).toBe("function");
|
|
63
|
-
expect(typeof mockAdapter.subscribe).toBe("function");
|
|
64
|
-
expect(typeof mockAdapter.unsubscribe).toBe("function");
|
|
65
|
-
expect(typeof mockAdapter.isConnected).toBe("function");
|
|
66
|
-
expect(typeof mockAdapter.healthCheck).toBe("function");
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("GCPPubSubAdapter", () => {
|
|
72
|
-
it("should create adapter with config from environment", () => {
|
|
73
|
-
const originalProject = process.env.GOOGLE_CLOUD_PROJECT;
|
|
74
|
-
process.env.GOOGLE_CLOUD_PROJECT = "test-project";
|
|
75
|
-
|
|
76
|
-
const config = {
|
|
77
|
-
projectId: process.env.GOOGLE_CLOUD_PROJECT,
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
expect(config.projectId).toBe("test-project");
|
|
81
|
-
|
|
82
|
-
process.env.GOOGLE_CLOUD_PROJECT = originalProject;
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe("AWSSNSAdapter", () => {
|
|
87
|
-
it("should create adapter with config from environment", () => {
|
|
88
|
-
const originalRegion = process.env.AWS_REGION;
|
|
89
|
-
const originalWaitTime = process.env.SQS_WAIT_TIME_SECONDS;
|
|
90
|
-
|
|
91
|
-
process.env.AWS_REGION = "eu-central-1";
|
|
92
|
-
process.env.SQS_WAIT_TIME_SECONDS = "15";
|
|
93
|
-
|
|
94
|
-
const config = {
|
|
95
|
-
region: process.env.AWS_REGION || "us-east-1",
|
|
96
|
-
waitTimeSeconds: Number.parseInt(process.env.SQS_WAIT_TIME_SECONDS || "20", 10),
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
expect(config.region).toBe("eu-central-1");
|
|
100
|
-
expect(config.waitTimeSeconds).toBe(15);
|
|
101
|
-
|
|
102
|
-
process.env.AWS_REGION = originalRegion;
|
|
103
|
-
process.env.SQS_WAIT_TIME_SECONDS = originalWaitTime;
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe("AzureServiceBusAdapter", () => {
|
|
108
|
-
it("should create adapter with config from environment", () => {
|
|
109
|
-
const originalConnStr = process.env.AZURE_SERVICE_BUS_CONNECTION_STRING;
|
|
110
|
-
const testConnStr = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=abc";
|
|
111
|
-
process.env.AZURE_SERVICE_BUS_CONNECTION_STRING = testConnStr;
|
|
112
|
-
|
|
113
|
-
const config = {
|
|
114
|
-
connectionString: process.env.AZURE_SERVICE_BUS_CONNECTION_STRING,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
expect(config.connectionString).toBe(testConnStr);
|
|
118
|
-
|
|
119
|
-
process.env.AZURE_SERVICE_BUS_CONNECTION_STRING = originalConnStr;
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe("PubSubTriggerOpts Schema", () => {
|
|
124
|
-
it("should validate pub/sub trigger configuration", () => {
|
|
125
|
-
const validConfig = {
|
|
126
|
-
provider: "gcp" as const,
|
|
127
|
-
topic: "my-topic",
|
|
128
|
-
subscription: "my-subscription",
|
|
129
|
-
ack: true,
|
|
130
|
-
maxMessages: 10,
|
|
131
|
-
ackDeadline: 30,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
expect(validConfig.provider).toBe("gcp");
|
|
135
|
-
expect(validConfig.topic).toBe("my-topic");
|
|
136
|
-
expect(validConfig.subscription).toBe("my-subscription");
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("should support all provider types", () => {
|
|
140
|
-
const providers = ["gcp", "aws", "azure"];
|
|
141
|
-
|
|
142
|
-
for (const provider of providers) {
|
|
143
|
-
const config = {
|
|
144
|
-
provider: provider as any,
|
|
145
|
-
topic: "test-topic",
|
|
146
|
-
subscription: "test-sub",
|
|
147
|
-
};
|
|
148
|
-
expect(config.provider).toBe(provider);
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
});
|