|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#5948] `getUiView` is the producer behind `GET /ui/view/:object/:type` |
| 4 | +// (`packages/rest/src/rest-server.ts`, which does a bare `res.json(view)` — |
| 5 | +// no envelope, no validation). Its declared response schema is |
| 6 | +// `GetUiViewResponseSchema` (`packages/spec/src/api/protocol.zod.ts`), which |
| 7 | +// resolves to `ViewSchema`. |
| 8 | +// |
| 9 | +// Until this fix the two shapes disagreed, and nothing in the repo could see |
| 10 | +// it: `GetUiViewResponseSchema` had no runtime reader anywhere, so the body |
| 11 | +// went out unchecked. Measured on `origin/main` before the change: |
| 12 | +// |
| 13 | +// real list body -> RED [unrecognized_keys] path=["list"] … `object` |
| 14 | +// real form body -> RED [unrecognized_keys] path=["form"] … `object`, `label` |
| 15 | +// |
| 16 | +// `ListViewSchema` / `FormViewSchema` are `strictObject` and never declared |
| 17 | +// `object`; the container (`ViewSchema`) is where `object` belongs and always |
| 18 | +// declared it. `form.label` was `Edit ${…}` — a rendered UI string that no |
| 19 | +// view schema declares at all. |
| 20 | +// |
| 21 | +// These tests are the standing guard on that agreement. They deliberately |
| 22 | +// parse the output of the REAL `getUiView` call rather than a hand-built |
| 23 | +// literal: a hand-written fixture would pin what this file believes the |
| 24 | +// producer emits, which is exactly the belief that was wrong before. Feeding |
| 25 | +// the production assembly path through the production schema is the only |
| 26 | +// version of this test that can fail when the producer drifts. |
| 27 | + |
| 28 | +import { describe, it, expect } from 'vitest'; |
| 29 | +import { GetUiViewResponseSchema } from '@objectstack/spec/api'; |
| 30 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 31 | + |
| 32 | +const SCHEMA = { |
| 33 | + name: 'account', |
| 34 | + label: 'Account', |
| 35 | + fields: { |
| 36 | + id: { name: 'id', type: 'text' }, |
| 37 | + name: { name: 'name', type: 'text', label: 'Name', required: true }, |
| 38 | + status: { name: 'status', type: 'text', label: 'Status' }, |
| 39 | + notes: { name: 'notes', type: 'textarea', label: 'Notes' }, |
| 40 | + secret: { name: 'secret', type: 'text', hidden: true }, |
| 41 | + created_at: { name: 'created_at', type: 'datetime' }, |
| 42 | + }, |
| 43 | +}; |
| 44 | + |
| 45 | +function protocolFor(schema: unknown = SCHEMA) { |
| 46 | + const engine = { registry: { getObject: () => schema } }; |
| 47 | + return new ObjectStackProtocolImplementation(engine as any); |
| 48 | +} |
| 49 | + |
| 50 | +/** Render zod issues into something a failure message can be read from. */ |
| 51 | +function explain(result: { success: boolean; error?: any }) { |
| 52 | + if (result.success) return 'GREEN'; |
| 53 | + return result.error.issues |
| 54 | + .map((i: any) => `[${i.code}] path=${JSON.stringify(i.path)} ${i.message}`) |
| 55 | + .join('\n'); |
| 56 | +} |
| 57 | + |
| 58 | +describe('[#5948] getUiView emits a body that satisfies its own declared schema', () => { |
| 59 | + it('list branch parses GREEN against GetUiViewResponseSchema', async () => { |
| 60 | + const p = protocolFor(); |
| 61 | + const body = await p.getUiView({ object: 'account', type: 'list' }); |
| 62 | + |
| 63 | + const parsed = GetUiViewResponseSchema.safeParse(body); |
| 64 | + expect(explain(parsed)).toBe('GREEN'); |
| 65 | + expect(parsed.success).toBe(true); |
| 66 | + }); |
| 67 | + |
| 68 | + it('form branch parses GREEN against GetUiViewResponseSchema', async () => { |
| 69 | + const p = protocolFor(); |
| 70 | + const body = await p.getUiView({ object: 'account', type: 'form' }); |
| 71 | + |
| 72 | + const parsed = GetUiViewResponseSchema.safeParse(body); |
| 73 | + expect(explain(parsed)).toBe('GREEN'); |
| 74 | + expect(parsed.success).toBe(true); |
| 75 | + }); |
| 76 | + |
| 77 | + // The three keys this issue removed, pinned by name. The GREEN assertions |
| 78 | + // above already fail if any of them comes back — `strictObject` rejects |
| 79 | + // them — but naming them here is what makes a future failure legible |
| 80 | + // instead of a bare "unrecognized_keys" the next reader has to decode. |
| 81 | + it('the object binding sits on the container, never on the view member', async () => { |
| 82 | + const p = protocolFor(); |
| 83 | + |
| 84 | + const listBody: any = await p.getUiView({ object: 'account', type: 'list' }); |
| 85 | + expect(listBody.object).toBe('account'); |
| 86 | + expect(listBody.list).toBeDefined(); |
| 87 | + expect(listBody.list).not.toHaveProperty('object'); |
| 88 | + |
| 89 | + const formBody: any = await p.getUiView({ object: 'account', type: 'form' }); |
| 90 | + expect(formBody.object).toBe('account'); |
| 91 | + expect(formBody.form).toBeDefined(); |
| 92 | + expect(formBody.form).not.toHaveProperty('object'); |
| 93 | + }); |
| 94 | + |
| 95 | + it('the form view carries no rendered `label` heading', async () => { |
| 96 | + const p = protocolFor(); |
| 97 | + const formBody: any = await p.getUiView({ object: 'account', type: 'form' }); |
| 98 | + // `Edit ${schema.label}` was a UI string living in a metadata body. |
| 99 | + // `FormViewSchema` declares no `label`; the heading is the UI's to compose. |
| 100 | + expect(formBody.form).not.toHaveProperty('label'); |
| 101 | + }); |
| 102 | + |
| 103 | + // Guards the half of the payload that did NOT move: `ListViewSchema` DOES |
| 104 | + // declare `label`, so the list view keeps its own. A future cleanup that |
| 105 | + // over-reaches and strips this one too would be caught here rather than |
| 106 | + // silently degrading the list header. |
| 107 | + it('the list view keeps its declared `label`', async () => { |
| 108 | + const p = protocolFor(); |
| 109 | + const listBody: any = await p.getUiView({ object: 'account', type: 'list' }); |
| 110 | + expect(listBody.list.label).toBe('Account'); |
| 111 | + }); |
| 112 | + |
| 113 | + // The producer builds `sort` only when the object has `created_at`. The |
| 114 | + // no-`created_at` branch emits `sort: undefined`, which is a different |
| 115 | + // parse path (optional vs present-but-undefined) and was never exercised. |
| 116 | + it('parses GREEN for an object with no created_at (sort omitted)', async () => { |
| 117 | + const p = protocolFor({ |
| 118 | + name: 'tag', |
| 119 | + label: 'Tag', |
| 120 | + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, |
| 121 | + }); |
| 122 | + |
| 123 | + const listBody = await p.getUiView({ object: 'tag', type: 'list' }); |
| 124 | + const parsed = GetUiViewResponseSchema.safeParse(listBody); |
| 125 | + expect(explain(parsed)).toBe('GREEN'); |
| 126 | + }); |
| 127 | +}); |
0 commit comments