@forwardimpact/libmock 0.1.2 → 0.1.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.
- package/package.json +1 -1
- package/src/mock/clients.js +96 -18
package/package.json
CHANGED
package/src/mock/clients.js
CHANGED
|
@@ -167,6 +167,27 @@ export function createMockDiscussionClient(overrides = {}) {
|
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Reject a request that omits `tenant_id`, mirroring `services/bridge`'s
|
|
172
|
+
* `requireTenant` guard. Every tenant-scoped RPC carries a `tenant_id` in
|
|
173
|
+
* both deployment modes (single-tenant binds the literal `"default"`); an
|
|
174
|
+
* empty value is a caller error, not an empty result. The stateful mock
|
|
175
|
+
* applies the same guard so production callers that forget to thread a
|
|
176
|
+
* `tenant_id` fail in tests exactly as they would against the real service.
|
|
177
|
+
*
|
|
178
|
+
* @param {{tenant_id?: string}} obj
|
|
179
|
+
* @returns {string} the validated tenant id
|
|
180
|
+
*/
|
|
181
|
+
function requireTenant(obj) {
|
|
182
|
+
const tenant_id = obj?.tenant_id;
|
|
183
|
+
if (typeof tenant_id !== "string" || tenant_id.length === 0) {
|
|
184
|
+
throw Object.assign(new Error("tenant_id is required"), {
|
|
185
|
+
code: grpc.status.INVALID_ARGUMENT,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return tenant_id;
|
|
189
|
+
}
|
|
190
|
+
|
|
170
191
|
function coerceInt64Fields(obj) {
|
|
171
192
|
obj.open_rfcs ??= {};
|
|
172
193
|
obj.pending_callbacks ??= {};
|
|
@@ -194,24 +215,35 @@ export function createStatefulDiscussionClient() {
|
|
|
194
215
|
const records = new Map();
|
|
195
216
|
const origins = new Map();
|
|
196
217
|
const pending = new Map();
|
|
218
|
+
const inbox = new Map();
|
|
219
|
+
const inboxSeqs = new Map();
|
|
197
220
|
|
|
198
221
|
return {
|
|
199
222
|
SaveDiscussion: spy(async (req) => {
|
|
200
223
|
const obj = req?.toJSON?.() ?? req;
|
|
224
|
+
const tenant_id = requireTenant(obj);
|
|
201
225
|
coerceInt64Fields(obj);
|
|
202
|
-
|
|
226
|
+
// Tenant-scope the index key exactly like services/bridge:
|
|
227
|
+
// `${channel}:${tenant_id}:${discussion_id}`. The record keeps its
|
|
228
|
+
// tenant_id so cross-record RPCs can filter by it.
|
|
229
|
+
records.set(`${obj.channel}:${tenant_id}:${obj.discussion_id}`, obj);
|
|
203
230
|
return {};
|
|
204
231
|
}),
|
|
205
232
|
LoadDiscussion: spy(async (req) => {
|
|
206
233
|
const obj = req?.toJSON?.() ?? req;
|
|
207
|
-
const
|
|
234
|
+
const tenant_id = requireTenant(obj);
|
|
235
|
+
const key = `${obj.channel}:${tenant_id}:${obj.discussion_id}`;
|
|
208
236
|
const rec = records.get(key);
|
|
209
237
|
if (!rec) throw notFound();
|
|
210
238
|
return rec;
|
|
211
239
|
}),
|
|
212
240
|
LoadDiscussionByCorrelation: spy(async (req) => {
|
|
213
241
|
const obj = req?.toJSON?.() ?? req;
|
|
242
|
+
const tenant_id = requireTenant(obj);
|
|
214
243
|
for (const rec of records.values()) {
|
|
244
|
+
// Filter to the requesting tenant after the correlation scan so a
|
|
245
|
+
// correlation owned by tenant A is invisible to tenant B.
|
|
246
|
+
if (rec.tenant_id !== tenant_id) continue;
|
|
215
247
|
if (
|
|
216
248
|
Object.values(rec.pending_callbacks ?? {}).includes(
|
|
217
249
|
obj.correlation_id,
|
|
@@ -222,43 +254,89 @@ export function createStatefulDiscussionClient() {
|
|
|
222
254
|
}
|
|
223
255
|
throw notFound();
|
|
224
256
|
}),
|
|
225
|
-
ListOpenRecesses: spy(async () => {
|
|
257
|
+
ListOpenRecesses: spy(async (req) => {
|
|
258
|
+
const obj = req?.toJSON?.() ?? req;
|
|
259
|
+
const tenant_id = requireTenant(obj);
|
|
226
260
|
const refs = [];
|
|
227
|
-
for (const rec of records.values())
|
|
261
|
+
for (const rec of records.values()) {
|
|
262
|
+
if (rec.tenant_id !== tenant_id) continue;
|
|
228
263
|
for (const [cid, rfc] of Object.entries(rec.open_rfcs ?? {}))
|
|
229
264
|
if (typeof rfc.due_at === "number")
|
|
230
|
-
refs.push({ correlation_id: cid, due_at: rfc.due_at });
|
|
265
|
+
refs.push({ correlation_id: cid, due_at: rfc.due_at, tenant_id });
|
|
266
|
+
}
|
|
231
267
|
return { refs };
|
|
232
268
|
}),
|
|
233
269
|
HasOrigin: spy(async (req) => {
|
|
234
270
|
const obj = req?.toJSON?.() ?? req;
|
|
235
|
-
|
|
271
|
+
const tenant_id = requireTenant(obj);
|
|
272
|
+
return { exists: origins.has(`${tenant_id}:${obj.id}`) };
|
|
236
273
|
}),
|
|
237
274
|
RecordOrigin: spy(async (req) => {
|
|
238
275
|
const obj = req?.toJSON?.() ?? req;
|
|
239
|
-
|
|
276
|
+
const tenant_id = requireTenant(obj);
|
|
277
|
+
// Tenant-scope the origin key so a comment id recorded by tenant A is
|
|
278
|
+
// not seen as self-originated by tenant B.
|
|
279
|
+
origins.set(`${tenant_id}:${obj.id}`, obj);
|
|
240
280
|
return {};
|
|
241
281
|
}),
|
|
242
|
-
Sweep: spy(async () =>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
282
|
+
Sweep: spy(async (req) => {
|
|
283
|
+
requireTenant(req?.toJSON?.() ?? req);
|
|
284
|
+
return {
|
|
285
|
+
evicted_discussions: 0,
|
|
286
|
+
evicted_origins: 0,
|
|
287
|
+
evicted_pending: 0,
|
|
288
|
+
};
|
|
289
|
+
}),
|
|
247
290
|
PutPendingDispatch: spy(async (req) => {
|
|
248
291
|
const obj = req?.toJSON?.() ?? req;
|
|
292
|
+
const tenant_id = requireTenant(obj);
|
|
249
293
|
const p = obj.pending ?? obj;
|
|
250
|
-
pending.set(p.link_token
|
|
294
|
+
pending.set(`${tenant_id}:${p.link_token}`, p);
|
|
251
295
|
return {};
|
|
252
296
|
}),
|
|
253
297
|
ResolvePendingDispatch: spy(async (req) => {
|
|
254
298
|
const obj = req?.toJSON?.() ?? req;
|
|
255
|
-
const
|
|
256
|
-
const
|
|
299
|
+
const tenant_id = requireTenant(obj);
|
|
300
|
+
const key = `${tenant_id}:${obj.link_token}`;
|
|
301
|
+
const rec = pending.get(key);
|
|
257
302
|
if (!rec) throw notFound();
|
|
258
|
-
pending.delete(
|
|
303
|
+
pending.delete(key);
|
|
259
304
|
return rec;
|
|
260
305
|
}),
|
|
261
|
-
EnqueueInbox: spy(async () =>
|
|
262
|
-
|
|
306
|
+
EnqueueInbox: spy(async (req) => {
|
|
307
|
+
const obj = req?.toJSON?.() ?? req;
|
|
308
|
+
const tenant_id = requireTenant(obj);
|
|
309
|
+
const msg = obj.message ?? {};
|
|
310
|
+
// Queue per (tenant_id, correlation_id) so two tenants never collide
|
|
311
|
+
// on a shared correlation id.
|
|
312
|
+
const seqKey = `${tenant_id}:${msg.correlation_id}`;
|
|
313
|
+
const seq = (inboxSeqs.get(seqKey) ?? 0) + 1;
|
|
314
|
+
inboxSeqs.set(seqKey, seq);
|
|
315
|
+
inbox.set(`${seqKey}:${seq}`, {
|
|
316
|
+
tenant_id,
|
|
317
|
+
correlation_id: msg.correlation_id,
|
|
318
|
+
seq,
|
|
319
|
+
text: msg.text ?? "",
|
|
320
|
+
author: msg.author ?? "",
|
|
321
|
+
enqueued_at: msg.enqueued_at ?? 0,
|
|
322
|
+
});
|
|
323
|
+
return {};
|
|
324
|
+
}),
|
|
325
|
+
DrainInbox: spy(async (req) => {
|
|
326
|
+
const obj = req?.toJSON?.() ?? req;
|
|
327
|
+
const tenant_id = requireTenant(obj);
|
|
328
|
+
const messages = [];
|
|
329
|
+
for (const rec of inbox.values()) {
|
|
330
|
+
if (
|
|
331
|
+
rec.tenant_id === tenant_id &&
|
|
332
|
+
rec.correlation_id === obj.correlation_id &&
|
|
333
|
+
(rec.seq ?? 0) > (obj.since_seq ?? 0)
|
|
334
|
+
) {
|
|
335
|
+
messages.push(rec);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
messages.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
339
|
+
return { messages };
|
|
340
|
+
}),
|
|
263
341
|
};
|
|
264
342
|
}
|