|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #16159 — the LAST row of the card's eleven-row census: `ValidationError` |
| 5 | + * publishes its ADR-0112 `code` as an importable constant. |
| 6 | + * |
| 7 | + * ## What this pins, and why each assertion is here |
| 8 | + * |
| 9 | + * `@objectstack/objectql` declares BOTH realms in its own `exports` (`import` |
| 10 | + * to `dist/index.mjs`, `require` to `dist/index.js`), so a consumer holding the |
| 11 | + * other realm's copy of this class gets `instanceof` === false — measured on |
| 12 | + * #14936, and silent. The sound route is a `code` compare, and until this |
| 13 | + * change the only way to write one was to RE-SPELL the wire string in the |
| 14 | + * consumer's own package: that acquires a `check:error-code-provenance` stamp |
| 15 | + * site there and can then drift from what this engine throws with no compile |
| 16 | + * error to say so. Two in-repo recognizers do exactly that today |
| 17 | + * (`packages/types/src/validation-failure.ts` and |
| 18 | + * `packages/rest/src/error-response.ts`), each holding its own copy of the |
| 19 | + * string. |
| 20 | + * |
| 21 | + * ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status` |
| 22 | + * minimum reduces here to `code` plus the field that discriminates the refusal |
| 23 | + * (`fields[]`). ⛔ Inventing a `status` on the class to satisfy a habit would be |
| 24 | + * new published surface, and that is not what this card converts. |
| 25 | + * |
| 26 | + * Six facts, each its own case so a failure reads as the specific regression: |
| 27 | + * |
| 28 | + * 1. the constant holds the exact wire string, spelled LITERALLY here on |
| 29 | + * purpose. The test layer sits outside `check:error-code-provenance`'s |
| 30 | + * scanned population, so pinning it costs no stamp site while making a |
| 31 | + * silent rename of a published code impossible to pass off as "still the |
| 32 | + * same code". ⛔ This is the byte-identity fence — the conversion moves |
| 33 | + * where a spelling lives, never what it says. ⛔ Do not "simplify" it into |
| 34 | + * a constant compare: a pin that reads the constant cannot catch the |
| 35 | + * constant being wrong, and every OTHER case in this file compares against |
| 36 | + * the constant, so this is the only case that can. |
| 37 | + * 2. the constant IS the code a real refusal carries, asserted with `name` |
| 38 | + * and with `fields[]` — the field the class exists to report. ⛔ Never a |
| 39 | + * bare `toThrow()`: a throw-shaped assertion stays green when a DIFFERENT |
| 40 | + * refusal fires one step later, which is exactly the confusion `code` is |
| 41 | + * meant to end. |
| 42 | + * 3. it is reachable from the package BARREL, which is the whole affordance |
| 43 | + * this card buys — a constant a consumer cannot import is not an answer to |
| 44 | + * "identify it by `code`" — and it is what a future barrel edit would lose |
| 45 | + * silently. |
| 46 | + * 4. the barrel's constant and the barrel's already-exported class name the |
| 47 | + * same refusal. Both routes are published, so a consumer can hold either |
| 48 | + * and they must agree. |
| 49 | + * 5. a `code` compare matches a foreign-realm copy of the refusal where |
| 50 | + * `instanceof` returns false. THE CONTROL, and the reason the convention |
| 51 | + * exists (#14936). Without this case the others would pass just as happily |
| 52 | + * against an `instanceof`-based recommendation — the thing this card |
| 53 | + * replaces. |
| 54 | + * 6. ⛔ the constant is NOT the sibling validation spelling. `secret-fields.ts` |
| 55 | + * publishes `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'`, and the |
| 56 | + * card explicitly leaves "whether they should converge" unruled. This case |
| 57 | + * pins that this conversion did NOT quietly converge them: the two remain |
| 58 | + * two, and a future ruling that merges them will fail HERE first, which is |
| 59 | + * where a rename of a registered wire code should be forced to argue for |
| 60 | + * itself rather than arriving as a side effect. |
| 61 | + */ |
| 62 | + |
| 63 | +import { describe, it, expect } from 'vitest'; |
| 64 | +import { ValidationError, VALIDATION_FAILED_CODE } from './validation/record-validator.js'; |
| 65 | +import { EMPTY_CREDENTIAL_REFUSAL_CODE } from './secret-fields.js'; |
| 66 | +import * as barrel from './index.js'; |
| 67 | + |
| 68 | +describe('#16159 ValidationError publishes its code as a constant', () => { |
| 69 | + it('the constant holds the exact wire string it replaced', () => { |
| 70 | + expect(VALIDATION_FAILED_CODE).toBe('VALIDATION_FAILED'); |
| 71 | + }); |
| 72 | + |
| 73 | + it('the constant IS the code a real record-validation refusal carries', () => { |
| 74 | + const err = new ValidationError([ |
| 75 | + { field: 'amount', code: 'required', message: 'Amount is required' }, |
| 76 | + ]); |
| 77 | + |
| 78 | + expect(err.code).toBe(VALIDATION_FAILED_CODE); |
| 79 | + expect(err.name).toBe('ValidationError'); |
| 80 | + // `fields[]` is what a form acts on — the per-field breakdown this refusal |
| 81 | + // exists to carry, and the half a caller reads after branching on `code`. |
| 82 | + expect(err.fields).toEqual([ |
| 83 | + { field: 'amount', code: 'required', message: 'Amount is required' }, |
| 84 | + ]); |
| 85 | + // The top-level message carries the HUMAN text, which is what generic UI |
| 86 | + // surfaces display verbatim. |
| 87 | + expect(err.message).toBe('Amount is required'); |
| 88 | + }); |
| 89 | + |
| 90 | + it('it is re-exported from the package barrel, which is where a consumer reaches it', () => { |
| 91 | + // Identity, not equality: a barrel that re-declared the string instead of |
| 92 | + // re-exporting the constant would satisfy `toBe` on the VALUE while having |
| 93 | + // re-introduced exactly the second spelling this card exists to remove. |
| 94 | + expect(barrel.VALIDATION_FAILED_CODE).toBe(VALIDATION_FAILED_CODE); |
| 95 | + }); |
| 96 | + |
| 97 | + it("the barrel's constant and the barrel's already-exported class name the same refusal", () => { |
| 98 | + const err = new barrel.ValidationError([ |
| 99 | + { field: 'email', code: 'invalid_format', message: 'Not an email' }, |
| 100 | + ]); |
| 101 | + expect(err.code).toBe(barrel.VALIDATION_FAILED_CODE); |
| 102 | + }); |
| 103 | + |
| 104 | + it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => { |
| 105 | + // What a consumer holding the other realm's copy of this module actually |
| 106 | + // has: a structurally identical refusal from a DIFFERENT class object. |
| 107 | + class ValidationErrorOtherRealmCopy extends Error { |
| 108 | + readonly code = 'VALIDATION_FAILED'; |
| 109 | + } |
| 110 | + const fromOtherRealm = new ValidationErrorOtherRealmCopy(); |
| 111 | + |
| 112 | + // THE CONTROL. Without this line the assertion below would pass against an |
| 113 | + // `instanceof` recommendation too, i.e. against the defect the convention |
| 114 | + // exists to avoid. |
| 115 | + expect(fromOtherRealm instanceof ValidationError).toBe(false); |
| 116 | + expect(fromOtherRealm.code).toBe(VALIDATION_FAILED_CODE); |
| 117 | + }); |
| 118 | + |
| 119 | + it('⛔ it did NOT converge with the sibling `VALIDATION_ERROR` spelling — that question stays open', () => { |
| 120 | + // The card fences this off in its own words: EMPTY_CREDENTIAL_REFUSAL_CODE |
| 121 | + // is already 'VALIDATION_ERROR' while this site uses 'VALIDATION_FAILED', |
| 122 | + // "and whether they should converge is a question this card does not |
| 123 | + // answer". Publishing the current spelling must not decide it by side |
| 124 | + // effect, so the divergence is pinned rather than left to be noticed. |
| 125 | + expect(EMPTY_CREDENTIAL_REFUSAL_CODE).toBe('VALIDATION_ERROR'); |
| 126 | + expect(VALIDATION_FAILED_CODE).not.toBe(EMPTY_CREDENTIAL_REFUSAL_CODE); |
| 127 | + }); |
| 128 | +}); |
0 commit comments