|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect, beforeEach } from 'vitest'; |
| 4 | +import { |
| 5 | + ENGINE_DELETE_DISPATCH_CASES, |
| 6 | + ENGINE_DELETE_REJECT_MESSAGE, |
| 7 | + assertEngineDeleteDispatch, |
| 8 | +} from '@objectstack/objectql'; |
4 | 9 | import { DbQueueAdapter } from './db-queue-adapter.js'; |
5 | 10 |
|
6 | 11 | /** |
@@ -54,15 +59,26 @@ function makeFakeEngine() { |
54 | 59 | return r; |
55 | 60 | }, |
56 | 61 | async delete(table: string, opts: any) { |
57 | | - // Real-engine contract: the target id lives at `where.id` — there is no |
58 | | - // top-level `id` option. The mock used to accept `opts.id`, a signature |
59 | | - // the real engine rejects, which is exactly how the adapter's broken |
60 | | - // `{ id }` bags stayed green (#4371 option-2 survey). |
61 | | - const id = opts?.where?.id; |
62 | | - if (id == null) throw new Error('Delete requires an ID or options.multi=true'); |
| 62 | + // [#4550/#5198] Opened with ObjectQL.delete's OWN dispatch predicate. |
| 63 | + // What stood here was a hand-mirrored `if (opts?.where?.id == null)` — |
| 64 | + // written for #4371 to stop the mock accepting the top-level `{ id }` |
| 65 | + // bags the real engine rejects, and correct about that. But a mirror is a |
| 66 | + // second copy of the contract, and it was looser than the producer in |
| 67 | + // both directions: `where: { id: { $in: [...] } }` only LOOKS like an id |
| 68 | + // (a multi-row predicate the engine refuses without `multi`) and the |
| 69 | + // mirror waved it through, while `{ multi: true }` with no `where` is a |
| 70 | + // shape the engine ACCEPTS and the mirror threw on. A double that imports |
| 71 | + // the decision cannot drift from it; the same predicate already opens the |
| 72 | + // sibling `job-queue-retention.test.ts` fake in this package. |
| 73 | + const dispatch = assertEngineDeleteDispatch(opts); |
63 | 74 | const t = tables.get(table) ?? []; |
64 | | - tables.set(table, t.filter((r) => r.id !== id)); |
65 | | - return { id }; |
| 75 | + if (dispatch.kind === 'multi') { |
| 76 | + const keep = t.filter((r) => !matches(r, opts?.where ?? {})); |
| 77 | + tables.set(table, keep); |
| 78 | + return t.length - keep.length; // drivers report a deleted count |
| 79 | + } |
| 80 | + tables.set(table, t.filter((r) => r.id !== dispatch.id)); |
| 81 | + return { id: dispatch.id }; |
66 | 82 | }, |
67 | 83 | }; |
68 | 84 | } |
@@ -232,3 +248,82 @@ describe('DbQueueAdapter', () => { |
232 | 248 | expect(await adapter.getQueueSize('mix')).toBe(0); |
233 | 249 | }); |
234 | 250 | }); |
| 251 | + |
| 252 | +// --------------------------------------------------------------------------- |
| 253 | +// The double itself, measured against the producer (#4550 / #5198) |
| 254 | +// --------------------------------------------------------------------------- |
| 255 | +// |
| 256 | +// Every assertion above is only worth what this fake's fidelity is worth: a |
| 257 | +// double that accepts a call the real engine refuses turns a green suite into |
| 258 | +// no suite at all, on exactly the path the double was introduced for (#4434). |
| 259 | +// So the fake is driven against ObjectQL.delete's OWN published case-set here, |
| 260 | +// rather than trusted because its `delete` now names the right function. |
| 261 | +// |
| 262 | +// This is what the deleted `scripts/engine-double-contract.baseline.json` DEBT |
| 263 | +// entry bought, and why the entry could go: the gate proves the predicate is |
| 264 | +// CALLED, and these two tests prove the call is answered — the by-id and multi |
| 265 | +// branches route by verdict, and every shape the engine rejects the fake |
| 266 | +// rejects, with the producer's own message. |
| 267 | + |
| 268 | +describe('makeFakeEngine().delete conforms to ObjectQL.delete (#4550)', () => { |
| 269 | + it.each(ENGINE_DELETE_DISPATCH_CASES.map((c) => [c.what, c] as const))( |
| 270 | + 'agrees with the engine on %s', |
| 271 | + async (_what, c) => { |
| 272 | + const engine = makeFakeEngine(); |
| 273 | + engine.tables.set('sys_job_queue', [{ id: 'rec_1', rule_id: 'r1' }]); |
| 274 | + const call = engine.delete('sys_job_queue', c.options as any); |
| 275 | + if (c.expect === 'reject') { |
| 276 | + // Not merely "throws": the same message a real server answers with, so |
| 277 | + // the fake's rejection surface cannot drift from the producer's. |
| 278 | + await expect(call).rejects.toThrow(ENGINE_DELETE_REJECT_MESSAGE); |
| 279 | + // …and a refused call must not have deleted anything on its way out. |
| 280 | + expect(engine.tables.get('sys_job_queue')).toHaveLength(1); |
| 281 | + return; |
| 282 | + } |
| 283 | + await expect(call).resolves.toBeDefined(); |
| 284 | + }, |
| 285 | + ); |
| 286 | + |
| 287 | + it('routes by the verdict, not by guessing at `where`', async () => { |
| 288 | + const engine = makeFakeEngine(); |
| 289 | + const rows = () => engine.tables.get('sys_job_queue') ?? []; |
| 290 | + |
| 291 | + // by-id: the scalar id is the ONLY row removed, siblings survive. |
| 292 | + engine.tables.set('sys_job_queue', [{ id: 'a' }, { id: 'b' }]); |
| 293 | + expect(await engine.delete('sys_job_queue', { where: { id: 'a' } })).toEqual({ id: 'a' }); |
| 294 | + expect(rows().map((r: any) => r.id)).toEqual(['b']); |
| 295 | + |
| 296 | + // multi: the predicate matches many, and the fake reports the count a |
| 297 | + // driver's `deleteMany` reports. |
| 298 | + engine.tables.set('sys_job_queue', [ |
| 299 | + { id: 'a', status: 'completed' }, |
| 300 | + { id: 'b', status: 'completed' }, |
| 301 | + { id: 'c', status: 'pending' }, |
| 302 | + ]); |
| 303 | + expect( |
| 304 | + await engine.delete('sys_job_queue', { where: { status: 'completed' }, multi: true }), |
| 305 | + ).toBe(2); |
| 306 | + expect(rows().map((r: any) => r.id)).toEqual(['c']); |
| 307 | + |
| 308 | + // `where: { id: { $in: […] } }` only LOOKS like an id: it is a multi-row |
| 309 | + // predicate, so without `multi` the engine rejects it — the exact case a |
| 310 | + // hand-mirrored `if (opts?.where?.id == null)` waves through, and the reason |
| 311 | + // the mirror had to go rather than be corrected in place. (This fake's |
| 312 | + // `matches` is equality-only by design — no fixture here sends an operator |
| 313 | + // predicate — so what is pinned is the dispatch verdict, not `$in` matching.) |
| 314 | + engine.tables.set('sys_job_queue', [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); |
| 315 | + await expect( |
| 316 | + engine.delete('sys_job_queue', { where: { id: { $in: ['a', 'b'] } } }), |
| 317 | + ).rejects.toThrow(ENGINE_DELETE_REJECT_MESSAGE); |
| 318 | + expect(rows()).toHaveLength(3); |
| 319 | + |
| 320 | + // The property the removed mirror was ORIGINALLY written for (#4371): a |
| 321 | + // top-level `{ id }` bag is not an address — the id lives at `where.id`. |
| 322 | + // It is not one of ENGINE_DELETE_DISPATCH_CASES, so it is asserted here |
| 323 | + // rather than left to lapse with the code that used to carry it. |
| 324 | + await expect( |
| 325 | + engine.delete('sys_job_queue', { id: 'a' } as any), |
| 326 | + ).rejects.toThrow(ENGINE_DELETE_REJECT_MESSAGE); |
| 327 | + expect(rows()).toHaveLength(3); |
| 328 | + }); |
| 329 | +}); |
0 commit comments