-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathprotocol-view-identity-overlay.test.ts
More file actions
223 lines (211 loc) · 9.95 KB
/
Copy pathprotocol-view-identity-overlay.test.ts
File metadata and controls
223 lines (211 loc) · 9.95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
/**
* #2555 — a console personalization PUT (grid column sort, inline edit, …)
* sends only the raw view config: no top-level `viewKind`/`object`. Pre-fix,
* `saveMetaItem` persisted it verbatim and `getMetaItems` replaced the
* flattened package entry with the overlay row wholesale, so the identity
* fields vanished and the view switcher endpoint (which filters on
* `viewKind && object`) dropped the view permanently.
*
* Two independent guards are covered here end-to-end against a stubbed engine:
* • write path — `saveMetaItem` inherits the identity fields from the
* registry entry the overlay shadows before persisting;
* • read path — `getMetaItems` heals identity-less rows already in the DB
* (persisted by pre-fix saves) from the shadowed registry entry.
*/
// The flattened package entry `expandViewContainer` produces for the
// showcase's default task grid — the entry the runtime overlay shadows.
const flattened = {
name: 'showcase_task.default',
object: 'showcase_task',
viewKind: 'list',
label: 'All Tasks',
scope: 'package',
config: { type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: ['title'] },
};
// What the console actually PUTs back on a column sort — the view's raw
// config plus personalization state, no identity fields (captured from the
// sys_metadata row in the 3777 repro).
const personalization = {
type: 'grid',
data: { provider: 'object', object: 'showcase_task' },
columns: ['title'],
sort: [{ id: '29200fa8-c416-471e-9ca3-913f9308ad89', field: 'estimate_hours', order: 'desc' }],
};
interface Row {
id: string;
type: string;
name: string;
organization_id: string | null;
state: string;
metadata: string;
package_id?: string | null;
}
function makeStubEngine(registryViews: Record<string, unknown> = {}) {
const rows = new Map<string, Row>();
let nextId = 0;
const keyOf = (w: Record<string, unknown>) => `${w.type}|${w.name}|${w.organization_id ?? '__env__'}`;
const findRow = (w: Record<string, unknown>) => {
if (w.id !== undefined) {
for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
return null;
}
const r = rows.get(keyOf(w));
return r ? { key: keyOf(w), row: r } : null;
};
const engine: any = {
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
return findRow(opts.where)?.row ?? null;
},
async find(_t: string, opts: { where: Record<string, unknown> }) {
return Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (opts.where.organization_id !== undefined && r.organization_id !== opts.where.organization_id) return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
},
async insert(_t: string, data: Record<string, unknown>) {
if (_t === 'sys_metadata_audit') return { id: 'audit_skip' };
nextId += 1;
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
rows.set(keyOf(data), row);
return { id: row.id };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
const found = findRow(opts.where);
if (!found) return { id: null };
rows.set(found.key, { ...found.row, ...(data as any) });
return { id: found.row.id };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
registry: {
registerItem: () => {},
registerObject: () => {},
getItem: (type: string, name: string) => (type === 'view' || type === 'views') ? registryViews[name] : undefined,
listItems: (type: string) => (type === 'view' || type === 'views') ? Object.values(registryViews) : [],
isPackageDisabled: () => false,
},
};
return { engine, rows };
}
describe('view overlay identity (#2555)', () => {
it('write path: saveMetaItem inherits viewKind/object/label from the shadowed registry entry', async () => {
const { engine, rows } = makeStubEngine({ 'showcase_task.default': flattened });
const protocol = new ObjectStackProtocolImplementation(engine);
const result = await protocol.saveMetaItem({
type: 'view',
name: 'showcase_task.default',
item: { ...personalization },
});
expect(result.success).toBe(true);
const row = Array.from(rows.values()).find((r) => r.type === 'view');
expect(row).toBeTruthy();
const persisted = JSON.parse(row!.metadata);
// Identity inherited…
expect(persisted.viewKind).toBe('list');
expect(persisted.object).toBe('showcase_task');
expect(persisted.label).toBe('All Tasks');
expect(persisted.name).toBe('showcase_task.default');
// …and the personalization survives untouched.
expect(persisted.sort).toEqual(personalization.sort);
});
it('read path: getMetaItems heals a pre-fix identity-less overlay row from the shadowed entry', async () => {
const { engine } = makeStubEngine({ 'showcase_task.default': flattened });
// Seed the DB with a PRE-fix row: raw config + name, no identity.
await engine.insert('sys_metadata', {
type: 'view',
name: 'showcase_task.default',
organization_id: null,
state: 'active',
metadata: JSON.stringify({ ...personalization, name: 'showcase_task.default' }),
});
const protocol = new ObjectStackProtocolImplementation(engine);
const items = ((await protocol.getMetaItems({ type: 'view' })) as any).items as any[];
const item = items.find((i) => i?.name === 'showcase_task.default');
expect(item).toBeTruthy();
// The overlay still wins on content…
expect(item.sort).toEqual(personalization.sort);
// …but the identity fields the switcher filters on are back.
expect(item.viewKind).toBe('list');
expect(item.object).toBe('showcase_task');
expect(item.label).toBe('All Tasks');
});
it("read path: an overlay's own identity fields are not clobbered by the shadowed entry", async () => {
const { engine } = makeStubEngine({ 'showcase_task.default': flattened });
await engine.insert('sys_metadata', {
type: 'view',
name: 'showcase_task.default',
organization_id: null,
state: 'active',
metadata: JSON.stringify({
...personalization,
name: 'showcase_task.default',
viewKind: 'list',
object: 'showcase_task',
label: 'My Renamed Grid',
}),
});
const protocol = new ObjectStackProtocolImplementation(engine);
const items = ((await protocol.getMetaItems({ type: 'view' })) as any).items as any[];
const item = items.find((i) => i?.name === 'showcase_task.default');
expect(item.label).toBe('My Renamed Grid');
});
// #3095 — a standalone ViewItem record's `config` used to strip to `{}`
// under the container ViewSchema, so a broken config saved with a false 200.
// The `view` type now maps to the union schema that validates it genuinely.
it('write path rejects a ViewItem whose kanban config is broken (422)', async () => {
const { engine } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
await expect(
protocol.saveMetaItem({
type: 'view',
name: 'crm_lead.pipeline',
item: {
name: 'crm_lead.pipeline',
object: 'crm_lead',
viewKind: 'list',
// kanban config is missing the required groupByField.
config: { type: 'kanban', columns: ['name'], kanban: { summarizeField: 'amount', columns: ['name'] } },
},
}),
).rejects.toMatchObject({ code: 'invalid_metadata', status: 422 });
});
it('write path accepts a well-formed ViewItem record', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
const result = await protocol.saveMetaItem({
type: 'view',
name: 'crm_lead.all',
item: {
name: 'crm_lead.all',
object: 'crm_lead',
viewKind: 'list',
config: { type: 'grid', columns: ['name'], data: { provider: 'object', object: 'crm_lead' } },
},
});
expect(result.success).toBe(true);
expect(Array.from(rows.values()).some((r) => r.type === 'view')).toBe(true);
});
it('write path stays a plain name-stamp when the registry has no entry to inherit from', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
const result = await protocol.saveMetaItem({
type: 'view',
name: 'adhoc.view',
item: { ...personalization },
});
expect(result.success).toBe(true);
const row = Array.from(rows.values()).find((r) => r.type === 'view');
const persisted = JSON.parse(row!.metadata);
expect(persisted.name).toBe('adhoc.view');
expect('viewKind' in persisted).toBe(false);
});
});