|
59 | 59 | import { describe, it, expect, vi } from 'vitest'; |
60 | 60 | import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; |
61 | 61 | import { resolveThrownHttpError } from '@objectstack/types'; |
| 62 | +import { ErrorCode } from '@objectstack/spec/api'; |
62 | 63 | import { ObjectStackProtocolImplementation } from './protocol.js'; |
63 | 64 |
|
64 | 65 | const SCHEMA = { |
@@ -452,3 +453,141 @@ describe('[#8570] section 6 — anti-vacuity: the doubles are the shapes they cl |
452 | 453 | expect(resolveThrownHttpError(declaredServiceUnavailable()).declaredStatus).toBe(503); |
453 | 454 | }); |
454 | 455 | }); |
| 456 | + |
| 457 | +// ─── [#14723] The row speaks the WIRE spelling of a unique-constraint refusal ─ |
| 458 | + |
| 459 | +/** |
| 460 | + * MEASURED — `@objectstack/objectql`'s `DuplicateRecordError` as it reaches |
| 461 | + * these catches (`node`, the built class, `new DuplicateRecordError('leave_request', |
| 462 | + * raw, 'title')`): own properties `[stack, message, object, field, code, |
| 463 | + * status, name, cause, developerMessage]`, `code: 'DUPLICATE_RECORD'`, |
| 464 | + * `status: 409`, `name: 'DuplicateRecordError'`, the driver's error whole on |
| 465 | + * `cause`, and the platform sentence on `message` — no statement, no bound |
| 466 | + * value. Assignment ORDER matches the BUILT class: the two parameter |
| 467 | + * properties land first, then the `readonly` initialisers, then the |
| 468 | + * constructor body sets `name`, `cause` and `developerMessage`. The class |
| 469 | + * itself is deliberately not imported — |
| 470 | + * this package does not depend on `@objectstack/objectql`, and the row's rule |
| 471 | + * reads the envelope's declared `code` / `name`, never its prototype. |
| 472 | + */ |
| 473 | +function engineDuplicateRecordEnvelope(object = 'leave_request', field = 'title'): Error { |
| 474 | + const cause = driverFault( |
| 475 | + `insert into \`${object}\` (\`${field}\`, \`id\`) values ('dup-value', 'new-4') returning * - ` + |
| 476 | + `UNIQUE constraint failed: ${object}.${field}`, |
| 477 | + ); |
| 478 | + const err = new Error( |
| 479 | + `Duplicate record refused on '${object}': a unique constraint on '${field}' already holds this value. ` + |
| 480 | + 'No record was written.', |
| 481 | + ) as Error & { code: string; status: number; object: string; field: string; cause: unknown; developerMessage: string }; |
| 482 | + err.object = object; |
| 483 | + err.field = field; |
| 484 | + err.code = 'DUPLICATE_RECORD'; |
| 485 | + err.status = 409; |
| 486 | + err.name = 'DuplicateRecordError'; |
| 487 | + err.cause = cause; |
| 488 | + err.developerMessage = 'The driver refused this write as a unique-constraint violation.'; |
| 489 | + return err; |
| 490 | +} |
| 491 | + |
| 492 | +/** A hook that deliberately THROWS the registered `DUPLICATE_RECORD` — a different producer. */ |
| 493 | +function hookSpeakingDuplicateRecord(): Error { |
| 494 | + const err = new Error('this row is already there, says the hook') as Error & { code: string; status: number }; |
| 495 | + err.code = 'DUPLICATE_RECORD'; |
| 496 | + err.status = 409; |
| 497 | + return err; |
| 498 | +} |
| 499 | + |
| 500 | +const ENVELOPE_SENTENCE = |
| 501 | + "Duplicate record refused on 'leave_request': a unique constraint on 'title' already holds this value. " + |
| 502 | + 'No record was written.'; |
| 503 | + |
| 504 | +describe('[#14723] section 7 — a `DuplicateRecordError` row reports `UNIQUE_VIOLATION`, the route\'s one wire spelling', () => { |
| 505 | + it('the create loop: `UNIQUE_VIOLATION` / 409 / the platform sentence, and no `DUPLICATE_RECORD` anywhere in the payload', async () => { |
| 506 | + const { protocol } = makeEngine((verb) => (verb === 'insert' ? engineDuplicateRecordEnvelope() : undefined)); |
| 507 | + |
| 508 | + const res: any = await protocol.batchData({ |
| 509 | + object: 'leave_request', |
| 510 | + request: { operation: 'create', records: [{ data: { title: 'dup-value' } }] }, |
| 511 | + }); |
| 512 | + |
| 513 | + // The whole row, exactly: the code moved, the status and the sentence |
| 514 | + // did not (a declared 409 is quoted — #8502's positive list, unchanged). |
| 515 | + expect(res.results[0].errors[0]).toEqual({ |
| 516 | + code: 'UNIQUE_VIOLATION', |
| 517 | + message: ENVELOPE_SENTENCE, |
| 518 | + httpStatus: 409, |
| 519 | + }); |
| 520 | + const payload = JSON.stringify(res); |
| 521 | + expect(payload).not.toContain('DUPLICATE_RECORD'); |
| 522 | + // …and the driver's text stays on `cause`, which never reaches a row. |
| 523 | + expect(payload).not.toContain('insert into'); |
| 524 | + expect(payload).not.toContain('UNIQUE constraint failed'); |
| 525 | + expect(payload).not.toContain('SQLITE_ERROR'); |
| 526 | + }); |
| 527 | + |
| 528 | + it('the update and upsert loops report the same spelling — one helper, every loop', async () => { |
| 529 | + const a = makeEngine((verb) => (verb === 'update' ? engineDuplicateRecordEnvelope() : undefined)); |
| 530 | + const updateRes: any = await a.protocol.updateManyData({ |
| 531 | + object: 'leave_request', records: [{ id: 'r1', data: { title: 'dup-value' } }], |
| 532 | + }); |
| 533 | + expect(updateRes.results[0].errors[0].code).toBe('UNIQUE_VIOLATION'); |
| 534 | + expect(updateRes.results[0].errors[0].httpStatus).toBe(409); |
| 535 | + |
| 536 | + const b = makeEngine((verb) => (verb === 'update' ? engineDuplicateRecordEnvelope() : undefined)); |
| 537 | + const upsertRes: any = await b.protocol.batchData({ |
| 538 | + object: 'leave_request', |
| 539 | + request: { operation: 'upsert', records: [{ id: 'r1', data: { title: 'dup-value' } }] }, |
| 540 | + }); |
| 541 | + expect(upsertRes.results[0].errors[0].code).toBe('UNIQUE_VIOLATION'); |
| 542 | + expect(JSON.stringify(upsertRes)).not.toContain('DUPLICATE_RECORD'); |
| 543 | + }); |
| 544 | + |
| 545 | + it('[GUARD] a producer that merely SPEAKS `DUPLICATE_RECORD` is not the engine\'s envelope and keeps its own code', async () => { |
| 546 | + // The same discrimination the whole-request arm makes (#14389 §5): |
| 547 | + // the gate is the registered code AND the class name. A hook throwing |
| 548 | + // the registered member from its own body is a different producer |
| 549 | + // speaking a member of the vocabulary; the verbatim rule still applies. |
| 550 | + const { protocol } = makeEngine((verb) => (verb === 'insert' ? hookSpeakingDuplicateRecord() : undefined)); |
| 551 | + |
| 552 | + const res: any = await protocol.batchData({ |
| 553 | + object: 'leave_request', |
| 554 | + request: { operation: 'create', records: [{ data: { title: 'x' } }] }, |
| 555 | + }); |
| 556 | + |
| 557 | + expect(res.results[0].errors[0]).toEqual({ |
| 558 | + code: 'DUPLICATE_RECORD', |
| 559 | + message: 'this row is already there, says the hook', |
| 560 | + httpStatus: 409, |
| 561 | + }); |
| 562 | + }); |
| 563 | + |
| 564 | + it('[GUARD] the name alone does not qualify either — a `DuplicateRecordError` carrying another code keeps that code', async () => { |
| 565 | + const impostor = approvalsRecordLock('r1'); |
| 566 | + impostor.name = 'DuplicateRecordError'; |
| 567 | + const { protocol } = makeEngine((verb) => (verb === 'update' ? impostor : undefined)); |
| 568 | + |
| 569 | + const res: any = await protocol.updateManyData({ |
| 570 | + object: 'leave_request', records: [{ id: 'r1', data: { progress: 1 } }], |
| 571 | + }); |
| 572 | + |
| 573 | + expect(res.results[0].errors[0].code).toBe('RECORD_LOCKED'); |
| 574 | + }); |
| 575 | + |
| 576 | + it('anti-vacuity: BOTH spellings are registered, so the verbatim rule alone would have kept `DUPLICATE_RECORD`', () => { |
| 577 | + // The mapping is the only thing standing between the envelope and the |
| 578 | + // row's old spelling: `toRowApiError`'s verbatim limb admits any |
| 579 | + // registered code, and the engine's is registered. Reverting the |
| 580 | + // mapping therefore reddens section 7's first case with the row |
| 581 | + // reading `DUPLICATE_RECORD` again — measured, not assumed. |
| 582 | + expect(ErrorCode.safeParse('DUPLICATE_RECORD').success).toBe(true); |
| 583 | + expect(ErrorCode.safeParse('UNIQUE_VIOLATION').success).toBe(true); |
| 584 | + |
| 585 | + const env = engineDuplicateRecordEnvelope() as any; |
| 586 | + expect(Object.getOwnPropertyNames(env)).toEqual([ |
| 587 | + 'stack', 'message', 'object', 'field', 'code', 'status', 'name', 'cause', 'developerMessage', |
| 588 | + ]); |
| 589 | + expect(env.name).toBe('DuplicateRecordError'); |
| 590 | + expect(env.code).toBe('DUPLICATE_RECORD'); |
| 591 | + expect(resolveThrownHttpError(env).declaredStatus).toBe(409); |
| 592 | + }); |
| 593 | +}); |
0 commit comments