|
4 | 4 | DataEventType, |
5 | 5 | MetadataEventSchema, |
6 | 6 | DataEventSchema, |
| 7 | + BulkDataEventSchema, |
7 | 8 | type MetadataEventSubject, |
8 | 9 | } from './events.zod'; |
9 | 10 |
|
@@ -245,10 +246,10 @@ describe('DataEventSchema', () => { |
245 | 246 | // "declared = enforced" is a measurement rather than a sentence: the key is |
246 | 247 | // optional and NOTHING else — no default fabricates a tenant, absence has |
247 | 248 | // exactly one spelling, and a value that is not a non-empty string is |
248 | | - // refused at the path a producer can act on. `BulkDataEventSchema` is |
249 | | - // deliberately untouched here: a predicate write's affected set is a |
250 | | - // separate contract with its own tenant question (recorded on the change |
251 | | - // that adds this member), so nothing below pins that schema either way. |
| 249 | + // refused at the path a producer can act on. `BulkDataEventSchema` carries |
| 250 | + // the same key with a DIFFERENT absence reading — one organization for the |
| 251 | + // whole batch, or "not asserted" — pinned in its own block below; the two |
| 252 | + // blocks share the refusal pins so the key stays one spelling and one shape. |
252 | 253 | describe('organizationId', () => { |
253 | 254 | const base = { |
254 | 255 | id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a6', |
@@ -303,3 +304,102 @@ describe('DataEventSchema', () => { |
303 | 304 | }); |
304 | 305 | }); |
305 | 306 | }); |
| 307 | + |
| 308 | +describe('BulkDataEventSchema', () => { |
| 309 | + // The bulk twin of the DataEvent tenant term. Same spelling, same refusal |
| 310 | + // set, same optionality — and a deliberately DIFFERENT absence reading: a |
| 311 | + // bulk event names no rows, so an absent key says the producer did not |
| 312 | + // assert one organization for the batch, never "the rows belong to none". |
| 313 | + // Present means ONE organization for the whole batch (never per-row, never a |
| 314 | + // list), which is what keeps a tenant-scoped fan-out one comparison. |
| 315 | + describe('organizationId', () => { |
| 316 | + const base = { |
| 317 | + id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a7', |
| 318 | + type: 'data.records.updated', |
| 319 | + object: 'account', |
| 320 | + matched: 40, |
| 321 | + timestamp: '2026-09-04T00:00:00.000Z', |
| 322 | + } as const; |
| 323 | + |
| 324 | + it('parses without the key and does not fabricate one (absent = not asserted for the batch)', () => { |
| 325 | + const event = BulkDataEventSchema.parse(base); |
| 326 | + expect(Object.prototype.hasOwnProperty.call(event, 'organizationId')).toBe(false); |
| 327 | + expect(event.organizationId).toBeUndefined(); |
| 328 | + }); |
| 329 | + |
| 330 | + it('parses with the key and carries the one batch organization through verbatim', () => { |
| 331 | + const event = BulkDataEventSchema.parse({ ...base, organizationId: 'org_jia' }); |
| 332 | + expect(event.organizationId).toBe('org_jia'); |
| 333 | + expect(event.matched).toBe(40); |
| 334 | + }); |
| 335 | + |
| 336 | + it('refuses a non-string value with invalid_type at ["organizationId"]', () => { |
| 337 | + const result = BulkDataEventSchema.safeParse({ ...base, organizationId: 42 }); |
| 338 | + expect(result.success).toBe(false); |
| 339 | + if (result.success) throw new Error('unreachable'); |
| 340 | + expect(result.error.issues).toEqual([ |
| 341 | + expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }), |
| 342 | + ]); |
| 343 | + }); |
| 344 | + |
| 345 | + it('refuses null — "not asserted" has exactly one spelling, the missing key', () => { |
| 346 | + const result = BulkDataEventSchema.safeParse({ ...base, organizationId: null }); |
| 347 | + expect(result.success).toBe(false); |
| 348 | + if (result.success) throw new Error('unreachable'); |
| 349 | + expect(result.error.issues).toEqual([ |
| 350 | + expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }), |
| 351 | + ]); |
| 352 | + }); |
| 353 | + |
| 354 | + it('refuses the empty string — one organization is never spelled ""', () => { |
| 355 | + const result = BulkDataEventSchema.safeParse({ ...base, organizationId: '' }); |
| 356 | + expect(result.success).toBe(false); |
| 357 | + if (result.success) throw new Error('unreachable'); |
| 358 | + expect(result.error.issues).toEqual([ |
| 359 | + expect.objectContaining({ code: 'too_small', minimum: 1, path: ['organizationId'] }), |
| 360 | + ]); |
| 361 | + }); |
| 362 | + |
| 363 | + it('refuses a per-row list — the batch carries one organization, never an array', () => { |
| 364 | + const result = BulkDataEventSchema.safeParse({ ...base, organizationId: ['org_jia', 'org_yi'] }); |
| 365 | + expect(result.success).toBe(false); |
| 366 | + if (result.success) throw new Error('unreachable'); |
| 367 | + expect(result.error.issues).toEqual([ |
| 368 | + expect.objectContaining({ code: 'invalid_type', expected: 'string', path: ['organizationId'] }), |
| 369 | + ]); |
| 370 | + }); |
| 371 | + |
| 372 | + it('is the only member added — every pre-existing member is still declared, and no plural spelling exists', () => { |
| 373 | + expect(Object.keys(BulkDataEventSchema.shape).sort()).toEqual([ |
| 374 | + 'id', 'matched', 'object', 'organizationId', 'timestamp', 'type', 'userId', |
| 375 | + ]); |
| 376 | + expect('organizationIds' in BulkDataEventSchema.shape).toBe(false); |
| 377 | + expect('organizationIds' in DataEventSchema.shape).toBe(false); |
| 378 | + }); |
| 379 | + |
| 380 | + it('spells and shapes the key identically to DataEventSchema.organizationId', () => { |
| 381 | + // Structural identity of the two declarations: both optional, both a |
| 382 | + // string with the same minimum-length check, so a consumer discriminates |
| 383 | + // both event families on ONE key with ONE comparison. |
| 384 | + const bulk = BulkDataEventSchema.shape.organizationId; |
| 385 | + const single = DataEventSchema.shape.organizationId; |
| 386 | + expect(bulk.def.type).toBe(single.def.type); |
| 387 | + expect(bulk.def.type).toBe('optional'); |
| 388 | + expect(bulk.def.innerType.def.type).toBe(single.def.innerType.def.type); |
| 389 | + expect(bulk.def.innerType.def.type).toBe('string'); |
| 390 | + expect(bulk.def.innerType.def.checks).toEqual(single.def.innerType.def.checks); |
| 391 | + // And behaviourally: the same probes yield the same verdicts on both. |
| 392 | + for (const probe of ['', null, 42, ['org_jia']]) { |
| 393 | + const b = bulk.safeParse(probe); |
| 394 | + const s = single.safeParse(probe); |
| 395 | + expect(b.success).toBe(false); |
| 396 | + expect(s.success).toBe(false); |
| 397 | + if (b.success || s.success) throw new Error('unreachable'); |
| 398 | + expect(b.error.issues.map((i) => i.code)).toEqual(s.error.issues.map((i) => i.code)); |
| 399 | + } |
| 400 | + expect(bulk.safeParse('org_jia')).toEqual(single.safeParse('org_jia')); |
| 401 | + expect(bulk.safeParse(undefined).success).toBe(true); |
| 402 | + expect(single.safeParse(undefined).success).toBe(true); |
| 403 | + }); |
| 404 | + }); |
| 405 | +}); |
0 commit comments