@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/mock/clients.js +96 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libmock",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Shared mocks and test fixtures so every library and service tests the same way.",
5
5
  "keywords": [
6
6
  "test",
@@ -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
- records.set(obj.id, obj);
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 key = `${obj.channel}:${obj.discussion_id}`;
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
- return { exists: origins.has(obj.id) };
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
- origins.set(obj.id, obj);
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
- evicted_discussions: 0,
244
- evicted_origins: 0,
245
- evicted_pending: 0,
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, p);
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 token = obj.link_token;
256
- const rec = pending.get(token);
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(token);
303
+ pending.delete(key);
259
304
  return rec;
260
305
  }),
261
- EnqueueInbox: spy(async () => ({})),
262
- DrainInbox: spy(async () => ({ messages: [] })),
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
  }