|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The legacy dispatcher `/meta` if-chain answers the FSM state read under |
| 5 | + * BOTH spellings — `objects` and `object` — and that is a DELIBERATE, |
| 6 | + * RULED state, not residue nobody has got to yet. |
| 7 | + * |
| 8 | + * ## Why this file exists at all |
| 9 | + * |
| 10 | + * `route-ledger.ts` records this asymmetry in a `note`, and its own |
| 11 | + * conformance test says so in as many words: *"The ledger's per-route rows |
| 12 | + * are documentation; the machine contract here is domain-level"*. Nothing |
| 13 | + * checked that note against the code. A ledger note that quietly stops being |
| 14 | + * true passes every gate in this repo and is then trusted INSTEAD of the |
| 15 | + * source — so the note's load-bearing clause is pinned here as behaviour. |
| 16 | + * |
| 17 | + * ⇒ Delete the plural arm in `domains/meta.ts` and this file fails, pointing |
| 18 | + * at the ruling that has to be reopened first. That is the whole point: the |
| 19 | + * arm is protected by a decision, and the decision is now enforceable. |
| 20 | + * |
| 21 | + * ## The asymmetry, measured on both sides |
| 22 | + * |
| 23 | + * | front door | `/meta/objects/:name/state/:field` | pinned by | |
| 24 | + * |---|---|---| |
| 25 | + * | **REST** (`packages/rest` `RestServer`) | **transport 404** — no registration to match | `meta-published-and-state-routes.dogfood.test.ts`, `route-ledger-live-mount-parity.dogfood.test.ts`, `meta-route-registration-order.test.ts` | |
| 26 | + * | **`dispatch()`** (the `@objectstack/hono` `createHonoApp` catch-all — the documented embed shape) | **200, answered** | THIS FILE | |
| 27 | + * |
| 28 | + * `createMetaDomain` registers prefix `/meta` and hands the remainder to |
| 29 | + * `handleMetadataRequest`, whose FSM branch matches a hard-coded literal |
| 30 | + * PAIR. So the spelling that REST stopped registering in #9180 step ② is |
| 31 | + * still served wherever the dispatcher is the front door. |
| 32 | + * |
| 33 | + * ## Why it stays — the provenance, so nobody has to guess |
| 34 | + * |
| 35 | + * The maintainer's 2026-08-17 re-weigh of the #9180 ruling, item 3, verbatim |
| 36 | + * and untranslated: |
| 37 | + * |
| 38 | + * > 「② 照原样做;只需要修正 objectstack objectui cloud 中错误的写法。」 |
| 39 | + * |
| 40 | + * which the re-weigh records as: the boundary's tolerance stays for external |
| 41 | + * callers, **no new refusals beyond what step ① already shipped**, the |
| 42 | + * external break deferred with no scheduled window — reopening it is the |
| 43 | + * maintainer's call. Narrowing this arm would be a new refusal on a SECOND |
| 44 | + * surface, so it is out of every step's scope by construction. |
| 45 | + * |
| 46 | + * ⛔ **This is NOT the `META_URL_TO_SINGULAR` fold** whose retirement was |
| 47 | + * deferred, and conflating the two is the specific error to avoid. The fold |
| 48 | + * is a MAP consulted for `/meta/:type` requests; this is a literal `||` in an |
| 49 | + * if-chain that no request ever routes through the fold to reach. They are |
| 50 | + * separate mechanisms under separate decisions. The cases below make the |
| 51 | + * difference observable rather than asserted: `objectss`, `objectz` and even |
| 52 | + * `OBJECTS` all 404, because a literal pair recognises two spellings and |
| 53 | + * nothing else — a fold would have normalised at least one of them. |
| 54 | + * |
| 55 | + * ⛔ And it is NOT "the plural is supported". It is TOLERATED on one front |
| 56 | + * door and REFUSED on another. Flattening that in either direction is the |
| 57 | + * misreading this pin exists to prevent — hence the ledger-note assertion at |
| 58 | + * the bottom, which requires the row to keep naming both spellings. |
| 59 | + */ |
| 60 | + |
| 61 | +import { describe, it, expect, vi } from 'vitest'; |
| 62 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 63 | +import { ROUTE_LEDGER } from '../route-ledger.js'; |
| 64 | + |
| 65 | +/** |
| 66 | + * One object with one `state_machine` rule. Mirrors the fixture the dogfood |
| 67 | + * pin drives over real HTTP, so the two sides of the table above are |
| 68 | + * comparing the same question. |
| 69 | + */ |
| 70 | +const SCHEMA = { |
| 71 | + name: 'task', |
| 72 | + label: 'Task', |
| 73 | + fields: { id: { type: 'text' }, status: { type: 'text' } }, |
| 74 | + validations: [ |
| 75 | + { |
| 76 | + type: 'state_machine', |
| 77 | + name: 'task_status_fsm', |
| 78 | + field: 'status', |
| 79 | + transitions: { todo: ['backlog', 'in_progress'], done: [] }, |
| 80 | + }, |
| 81 | + ], |
| 82 | +}; |
| 83 | + |
| 84 | +function boot() { |
| 85 | + const getObject = vi.fn((name: string) => (name === 'task' ? SCHEMA : undefined)); |
| 86 | + const objectql = { registry: { getObject } }; |
| 87 | + |
| 88 | + // `dispatch()` re-resolves identity and overwrites any injected |
| 89 | + // `executionContext`, so the session has to come through the real |
| 90 | + // resolver — otherwise the domain's anonymous-deny answers 401 before the |
| 91 | + // route is ever looked at and the pin would measure the wrong refusal. |
| 92 | + const auth = { api: { getSession: async () => ({ user: { id: 'u_session' } }) } }; |
| 93 | + |
| 94 | + const services: Record<string, any> = { objectql, auth }; |
| 95 | + const get = (n: string) => services[n] ?? null; |
| 96 | + const kernel = { |
| 97 | + context: { getService: get }, |
| 98 | + getService: get, |
| 99 | + getServiceAsync: async (n: string) => get(n), |
| 100 | + } as any; |
| 101 | + |
| 102 | + return { dispatcher: new HttpDispatcher(kernel), getObject }; |
| 103 | +} |
| 104 | + |
| 105 | +const CTX = (): any => ({ request: { headers: {} }, environmentId: 'platform' }); |
| 106 | + |
| 107 | +/** The one answer both spellings must produce, spelled once. */ |
| 108 | +const ANSWER = { object: 'task', field: 'status', from: 'todo', next: ['backlog', 'in_progress'] }; |
| 109 | + |
| 110 | +describe('dispatcher /meta FSM state read — the deliberate plural tolerance (#10179)', () => { |
| 111 | + it('answers the CANONICAL singular spelling (control)', async () => { |
| 112 | + const { dispatcher } = boot(); |
| 113 | + |
| 114 | + const res = await dispatcher.dispatch( |
| 115 | + 'GET', '/meta/object/task/state/status', undefined, { from: 'todo' }, CTX(), |
| 116 | + ); |
| 117 | + |
| 118 | + expect(res.response?.status ?? 200).toBe(200); |
| 119 | + expect(res.response?.body?.data).toEqual(ANSWER); |
| 120 | + }); |
| 121 | + |
| 122 | + it('ALSO answers the plural spelling — the tolerance the 2026-08-17 re-weigh left in place', async () => { |
| 123 | + const { dispatcher } = boot(); |
| 124 | + |
| 125 | + const res = await dispatcher.dispatch( |
| 126 | + 'GET', '/meta/objects/task/state/status', undefined, { from: 'todo' }, CTX(), |
| 127 | + ); |
| 128 | + |
| 129 | + // ⛔ Do not "fix" this to a 404 because the REST twin is retired. That |
| 130 | + // is option (a) of #10179 — a new refusal on a second surface — and it |
| 131 | + // needs the maintainer, not a passing thought in a nearby diff. |
| 132 | + expect(res.response?.status ?? 200).toBe(200); |
| 133 | + expect(res.response?.body?.data).toEqual(ANSWER); |
| 134 | + }); |
| 135 | + |
| 136 | + it('both spellings reach the SAME handler, not two lookalike answers', async () => { |
| 137 | + // The two cases above could in principle be served by different code |
| 138 | + // paths that happen to agree. `getObject` being called with the same |
| 139 | + // canonical object name from both is what makes them one branch. |
| 140 | + const singular = boot(); |
| 141 | + await singular.dispatcher.dispatch( |
| 142 | + 'GET', '/meta/object/task/state/status', undefined, { from: 'todo' }, CTX(), |
| 143 | + ); |
| 144 | + const plural = boot(); |
| 145 | + await plural.dispatcher.dispatch( |
| 146 | + 'GET', '/meta/objects/task/state/status', undefined, { from: 'todo' }, CTX(), |
| 147 | + ); |
| 148 | + |
| 149 | + expect(singular.getObject).toHaveBeenCalledWith('task'); |
| 150 | + expect(plural.getObject).toHaveBeenCalledWith('task'); |
| 151 | + }); |
| 152 | + |
| 153 | + it.each(['objectss', 'objectz', 'OBJECTS'])( |
| 154 | + 'tolerates EXACTLY the two literals — `%s` is not answered', |
| 155 | + async (segment) => { |
| 156 | + // The control that stops this file passing vacuously: if the pin |
| 157 | + // above were green because the harness answers everything, these |
| 158 | + // would be answered too. Measured, not assumed — all three return |
| 159 | + // 404 RESOURCE_NOT_FOUND. |
| 160 | + // |
| 161 | + // It is also the sharpest evidence for the ⛔ in this file's header: |
| 162 | + // a hard-coded literal pair recognises `objects` and NOTHING else, |
| 163 | + // not even its own uppercase. A fold would have normalised at least |
| 164 | + // one of these. This arm never reaches `META_URL_TO_SINGULAR`. |
| 165 | + const { dispatcher } = boot(); |
| 166 | + |
| 167 | + const res = await dispatcher.dispatch( |
| 168 | + 'GET', `/meta/${segment}/task/state/status`, undefined, { from: 'todo' }, CTX(), |
| 169 | + ); |
| 170 | + |
| 171 | + expect(res.response?.status).toBe(404); |
| 172 | + expect(res.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); |
| 173 | + expect(res.response?.body?.data).toBeUndefined(); |
| 174 | + }, |
| 175 | + ); |
| 176 | + |
| 177 | + it('the plural is a TOLERANCE, not a second contract — an unknown object still 404s under it', async () => { |
| 178 | + // Keeps the pin honest in the other direction: the plural arm is the |
| 179 | + // same handler with the same refusals, not a laxer door. |
| 180 | + const { dispatcher } = boot(); |
| 181 | + |
| 182 | + const res = await dispatcher.dispatch( |
| 183 | + 'GET', '/meta/objects/zzz_not_a_real_object/state/status', undefined, { from: 'todo' }, CTX(), |
| 184 | + ); |
| 185 | + |
| 186 | + expect(res.response?.status).toBe(404); |
| 187 | + }); |
| 188 | + |
| 189 | + it('the ledger row keeps NAMING the asymmetry — the note is checked, not trusted', () => { |
| 190 | + const row = ROUTE_LEDGER.find((e) => e.route === 'GET /meta/object/:name/state/:field'); |
| 191 | + expect(row, 'the FSM state row must exist in the dispatcher ledger').toBeDefined(); |
| 192 | + |
| 193 | + // The row lists the CANONICAL spelling of a branch that answers two. |
| 194 | + // A row that stops saying so is a row that has started lying about the |
| 195 | + // surface — the exact failure this file was added to make loud. |
| 196 | + const note = row?.note ?? ''; |
| 197 | + expect(note).toContain('objects'); |
| 198 | + expect(note).toContain('object'); |
| 199 | + |
| 200 | + // …and it must keep the tolerance's provenance reachable, so the next |
| 201 | + // author finds the ruling instead of re-deriving it from the if-chain. |
| 202 | + expect(note).toContain('2026-08-17'); |
| 203 | + }); |
| 204 | +}); |
0 commit comments