|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #3095 — `ViewMetadataSchema` is the canonical schema the `view` metadata type |
| 5 | + * registers (save-time 422 validation + read-time diagnostics). It MUST validate |
| 6 | + * all three runtime `view` shapes GENUINELY, where the bare container |
| 7 | + * {@link ViewSchema} was a no-op (Zod strip-parsed ViewItem/personalization |
| 8 | + * bodies to `{}`, so a broken `config` sailed through "valid"). |
| 9 | + * |
| 10 | + * 1. defineView aggregate container (non-empty) |
| 11 | + * 2. standalone ViewItem record ({ name, object, viewKind, config }) |
| 12 | + * 3. flattened personalization overlay (raw config + inherited identity, #2555) |
| 13 | + */ |
| 14 | + |
| 15 | +import { describe, it, expect } from 'vitest'; |
| 16 | +import { z } from 'zod'; |
| 17 | +import { ViewMetadataSchema } from './view.zod'; |
| 18 | + |
| 19 | +const PLACEHOLDER_DATA = { provider: 'object', object: 'crm_lead' } as const; |
| 20 | + |
| 21 | +describe('ViewMetadataSchema — genuine validation across the three runtime shapes (#3095)', () => { |
| 22 | + // ── shape 2: standalone ViewItem record ─────────────────────────────────── |
| 23 | + describe('ViewItem record form', () => { |
| 24 | + it('accepts a well-formed list ViewItem', () => { |
| 25 | + const r = ViewMetadataSchema.safeParse({ |
| 26 | + name: 'crm_lead.all', |
| 27 | + object: 'crm_lead', |
| 28 | + viewKind: 'list', |
| 29 | + label: 'All Leads', |
| 30 | + config: { type: 'grid', columns: ['name'], data: PLACEHOLDER_DATA }, |
| 31 | + }); |
| 32 | + expect(r.success).toBe(true); |
| 33 | + }); |
| 34 | + |
| 35 | + it('REJECTS a ViewItem whose kanban config is missing groupByField (was a no-op under ViewSchema)', () => { |
| 36 | + const r = ViewMetadataSchema.safeParse({ |
| 37 | + name: 'crm_lead.pipeline', |
| 38 | + object: 'crm_lead', |
| 39 | + viewKind: 'list', |
| 40 | + config: { |
| 41 | + type: 'kanban', |
| 42 | + columns: ['name'], |
| 43 | + // groupByField is required by KanbanConfigSchema — omit it. |
| 44 | + kanban: { summarizeField: 'amount', columns: ['name'] }, |
| 45 | + }, |
| 46 | + }); |
| 47 | + expect(r.success).toBe(false); |
| 48 | + }); |
| 49 | + |
| 50 | + it('accepts a well-formed form ViewItem', () => { |
| 51 | + const r = ViewMetadataSchema.safeParse({ |
| 52 | + name: 'crm_lead.edit', |
| 53 | + object: 'crm_lead', |
| 54 | + viewKind: 'form', |
| 55 | + config: { type: 'simple', sections: [{ label: 'Details', fields: ['name'] }] }, |
| 56 | + }); |
| 57 | + expect(r.success).toBe(true); |
| 58 | + }); |
| 59 | + |
| 60 | + it('REJECTS a form ViewItem carrying an invalid form `type` (config validated, not stripped)', () => { |
| 61 | + const r = ViewMetadataSchema.safeParse({ |
| 62 | + name: 'crm_lead.edit', |
| 63 | + object: 'crm_lead', |
| 64 | + viewKind: 'form', |
| 65 | + config: { type: 'not_a_real_form_type' }, |
| 66 | + }); |
| 67 | + expect(r.success).toBe(false); |
| 68 | + }); |
| 69 | + }); |
| 70 | + |
| 71 | + // ── shape 1: defineView container ───────────────────────────────────────── |
| 72 | + describe('defineView container form', () => { |
| 73 | + it('accepts a non-empty container', () => { |
| 74 | + const r = ViewMetadataSchema.safeParse({ |
| 75 | + list: { type: 'grid', data: PLACEHOLDER_DATA, columns: [{ field: 'name' }] }, |
| 76 | + }); |
| 77 | + expect(r.success).toBe(true); |
| 78 | + }); |
| 79 | + |
| 80 | + it('REJECTS a container whose nested list is missing required columns', () => { |
| 81 | + const r = ViewMetadataSchema.safeParse({ |
| 82 | + list: { type: 'grid', data: PLACEHOLDER_DATA }, |
| 83 | + }); |
| 84 | + expect(r.success).toBe(false); |
| 85 | + }); |
| 86 | + |
| 87 | + it('REJECTS an explicitly-empty container (zero views — mirrors defineView)', () => { |
| 88 | + // A body that names container slots but fills none of them registers no |
| 89 | + // view; the container member's non-empty refine rejects it, and the |
| 90 | + // flattened members reject it via their container-key guards. |
| 91 | + expect(ViewMetadataSchema.safeParse({ listViews: {}, formViews: {} }).success).toBe(false); |
| 92 | + expect(ViewMetadataSchema.safeParse({ listViews: {} }).success).toBe(false); |
| 93 | + }); |
| 94 | + |
| 95 | + it('accepts a bare `{}` (legacy-compatible — the old ViewSchema also accepted it)', () => { |
| 96 | + // Not a regression: a truly empty body carries no viewKind/object, so |
| 97 | + // every consumer that filters on identity drops it. Pinned so the lenient |
| 98 | + // flattened-overlay branch behaviour is intentional, not accidental. |
| 99 | + expect(ViewMetadataSchema.safeParse({}).success).toBe(true); |
| 100 | + }); |
| 101 | + }); |
| 102 | + |
| 103 | + // ── shape 3: flattened personalization overlay (#2555) ──────────────────── |
| 104 | + describe('flattened personalization overlay form', () => { |
| 105 | + it('accepts a raw list config with identity inherited from the shadowed entry', () => { |
| 106 | + // The exact shape normalizeViewMetadata persists on a console column-sort PUT. |
| 107 | + const r = ViewMetadataSchema.safeParse({ |
| 108 | + type: 'grid', |
| 109 | + data: { provider: 'object', object: 'showcase_task' }, |
| 110 | + columns: ['title'], |
| 111 | + sort: [{ id: '29200fa8-c416-471e-9ca3-913f9308ad89', field: 'estimate_hours', order: 'desc' }], |
| 112 | + name: 'showcase_task.default', |
| 113 | + viewKind: 'list', |
| 114 | + object: 'showcase_task', |
| 115 | + label: 'All Tasks', |
| 116 | + }); |
| 117 | + expect(r.success).toBe(true); |
| 118 | + }); |
| 119 | + |
| 120 | + it('accepts a raw list config with NO identity (adhoc PUT, no registry entry to inherit from)', () => { |
| 121 | + const r = ViewMetadataSchema.safeParse({ |
| 122 | + type: 'grid', |
| 123 | + data: { provider: 'object', object: 'showcase_task' }, |
| 124 | + columns: ['title'], |
| 125 | + sort: [{ field: 'estimate_hours', order: 'desc' }], |
| 126 | + name: 'adhoc.view', |
| 127 | + }); |
| 128 | + expect(r.success).toBe(true); |
| 129 | + }); |
| 130 | + |
| 131 | + it('accepts a raw form config overlay', () => { |
| 132 | + const r = ViewMetadataSchema.safeParse({ |
| 133 | + type: 'simple', |
| 134 | + sections: [{ label: 'Details', fields: ['name'] }], |
| 135 | + name: 'crm_lead.edit', |
| 136 | + viewKind: 'form', |
| 137 | + object: 'crm_lead', |
| 138 | + }); |
| 139 | + expect(r.success).toBe(true); |
| 140 | + }); |
| 141 | + |
| 142 | + it('REJECTS a flattened list overlay whose kanban binding is broken (genuine, not stripped)', () => { |
| 143 | + const r = ViewMetadataSchema.safeParse({ |
| 144 | + type: 'kanban', |
| 145 | + columns: ['name'], |
| 146 | + kanban: { summarizeField: 'amount', columns: ['name'] }, // no groupByField |
| 147 | + name: 'crm_lead.pipeline', |
| 148 | + viewKind: 'list', |
| 149 | + object: 'crm_lead', |
| 150 | + }); |
| 151 | + expect(r.success).toBe(false); |
| 152 | + }); |
| 153 | + |
| 154 | + it('preserves auxiliary Studio round-trip keys without a strict-mode 422', () => { |
| 155 | + // isPinned/sortOrder ride along on the overlay; the schema validates but |
| 156 | + // must not reject unknown top-level aux keys (saveMetaItem persists verbatim). |
| 157 | + const r = ViewMetadataSchema.safeParse({ |
| 158 | + type: 'grid', |
| 159 | + data: PLACEHOLDER_DATA, |
| 160 | + columns: ['name'], |
| 161 | + name: 'crm_lead.all', |
| 162 | + viewKind: 'list', |
| 163 | + object: 'crm_lead', |
| 164 | + isPinned: true, |
| 165 | + sortOrder: 3, |
| 166 | + }); |
| 167 | + expect(r.success).toBe(true); |
| 168 | + }); |
| 169 | + }); |
| 170 | + |
| 171 | + // ── mutual exclusion: a broken record/container is never rescued ────────── |
| 172 | + describe('member exclusivity', () => { |
| 173 | + it('does not rescue a broken record via the flattened branch (config guard)', () => { |
| 174 | + // A record body carries a nested `config`; the flattened members pin |
| 175 | + // `config` to undefined, so a broken config cannot be silently stripped. |
| 176 | + const r = ViewMetadataSchema.safeParse({ |
| 177 | + name: 'crm_lead.pipeline', |
| 178 | + object: 'crm_lead', |
| 179 | + viewKind: 'list', |
| 180 | + config: { type: 'grid', columns: 'not-an-array' }, |
| 181 | + }); |
| 182 | + expect(r.success).toBe(false); |
| 183 | + }); |
| 184 | + |
| 185 | + it('does not rescue a broken container via the flattened branch (list guard)', () => { |
| 186 | + const r = ViewMetadataSchema.safeParse({ |
| 187 | + list: { type: 'grid', data: PLACEHOLDER_DATA }, // missing columns |
| 188 | + name: 'crm_lead.default', |
| 189 | + }); |
| 190 | + expect(r.success).toBe(false); |
| 191 | + }); |
| 192 | + }); |
| 193 | + |
| 194 | + // ── JSON Schema emission (/api/v1/meta/types/view) ──────────────────────── |
| 195 | + it('converts to a JSON Schema anyOf (union → anyOf) without throwing', () => { |
| 196 | + const json = z.toJSONSchema(ViewMetadataSchema, { unrepresentable: 'any' }) as Record<string, unknown>; |
| 197 | + expect(Array.isArray(json.anyOf)).toBe(true); |
| 198 | + expect((json.anyOf as unknown[]).length).toBe(4); |
| 199 | + }); |
| 200 | +}); |
0 commit comments