@object-ui/data-objectstack 17.5.0 → 17.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +219 -0
- package/dist/index.cjs +262 -357
- package/dist/index.d.cts +385 -448
- package/dist/index.d.ts +385 -448
- package/dist/index.js +258 -350
- package/package.json +5 -6
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* objectui#4271 — `MetadataClient.get()` honors its documented unwrapped-body
|
|
11
|
-
* contract.
|
|
12
|
-
*
|
|
13
|
-
* The framework answers `GET /meta/:type/:name` with the spec-declared
|
|
14
|
-
* envelope `{ type, name, item, ...protection fields }`
|
|
15
|
-
* (`GetMetaItemResponseSchema`; objectstack#5563 collapsed the read to that ONE
|
|
16
|
-
* shape). `get()` used to hand that envelope straight back while its own
|
|
17
|
-
* docblock promised the unwrapped body — so every consumer reading `obj.fields`
|
|
18
|
-
* saw `undefined`, which is how the whole field half of the permission matrix
|
|
19
|
-
* went dead for every object.
|
|
20
|
-
*
|
|
21
|
-
* The asymmetry that IS real and stays: `getDraft()` documents the envelope and
|
|
22
|
-
* its callers read `.item`, so it must keep returning what the server sent.
|
|
23
|
-
* A11/A10 below are the pair that pins both halves.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
27
|
-
import { MetadataClient, type MetadataError } from './metadata-client';
|
|
28
|
-
|
|
29
|
-
function mockFetch(handler: (url: string, init?: RequestInit) => Promise<Response>): typeof fetch {
|
|
30
|
-
return vi.fn(handler) as unknown as typeof fetch;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
34
|
-
return new Response(JSON.stringify(body), {
|
|
35
|
-
status: 200,
|
|
36
|
-
headers: { 'content-type': 'application/json' },
|
|
37
|
-
...init,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** A client whose single-item read answers `body`, recording the URLs it hit. */
|
|
42
|
-
function clientAnswering(body: unknown, init?: ResponseInit) {
|
|
43
|
-
const urls: string[] = [];
|
|
44
|
-
const c = new MetadataClient({
|
|
45
|
-
baseUrl: 'http://localhost:3000',
|
|
46
|
-
fetch: mockFetch(async (url) => {
|
|
47
|
-
urls.push(url);
|
|
48
|
-
return init ? new Response(JSON.stringify(body), init) : jsonResponse(body);
|
|
49
|
-
}),
|
|
50
|
-
});
|
|
51
|
-
return { c, urls };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The card's live shape: 21 fields under `item`, as showcase_project answers. */
|
|
55
|
-
const OBJECT_BODY = {
|
|
56
|
-
name: 'showcase_project',
|
|
57
|
-
label: 'Project',
|
|
58
|
-
fields: { name: { type: 'text' }, stage: { type: 'select' } },
|
|
59
|
-
};
|
|
60
|
-
const ENVELOPE = { type: 'object', name: 'showcase_project', item: OBJECT_BODY };
|
|
61
|
-
|
|
62
|
-
describe('objectui#4271 · MetadataClient.get() unwraps the spec envelope', () => {
|
|
63
|
-
it('A1: unwraps `{type,name,item}` to the item body', async () => {
|
|
64
|
-
const { c } = clientAnswering(ENVELOPE);
|
|
65
|
-
const obj = await c.get<Record<string, unknown>>('object', 'showcase_project');
|
|
66
|
-
expect(obj).toEqual(OBJECT_BODY);
|
|
67
|
-
// The headline consequence: the field map is reachable again.
|
|
68
|
-
expect(Object.keys((obj as any)?.fields ?? {})).toEqual(['name', 'stage']);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('A2: unwraps when the ADR-0008 protection carriers ride along', async () => {
|
|
72
|
-
// `GetMetaItemResponseSchema` spreads MetadataProtectionEnvelopeFields, so
|
|
73
|
-
// a real envelope carries more than three keys. Unwrapping must not be
|
|
74
|
-
// conditioned on the key COUNT.
|
|
75
|
-
const { c } = clientAnswering({
|
|
76
|
-
...ENVELOPE,
|
|
77
|
-
lock: { locked: false, reason: 'ok' },
|
|
78
|
-
provenance: { origin: 'package' },
|
|
79
|
-
});
|
|
80
|
-
expect(await c.get('object', 'showcase_project')).toEqual(OBJECT_BODY);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('A3: an envelope carrying a null item reads as null', async () => {
|
|
84
|
-
const { c } = clientAnswering({ type: 'object', name: 'ghost', item: null });
|
|
85
|
-
expect(await c.get('object', 'ghost')).toBeNull();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('objectui#4271 · non-envelope responses pass through UNCHANGED', () => {
|
|
90
|
-
it('A4: a bare body (older server / already-unwrapped) is returned as-is', async () => {
|
|
91
|
-
const { c } = clientAnswering(OBJECT_BODY);
|
|
92
|
-
expect(await c.get('object', 'showcase_project')).toEqual(OBJECT_BODY);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('A5: type+name WITHOUT an `item` key is not unwrapped', async () => {
|
|
96
|
-
// A metadata document may legitimately carry `type` and `name` of its own
|
|
97
|
-
// (a view is `{name, type: "grid", ...}`). Absent `item`, it is the body.
|
|
98
|
-
const view = { name: 'all_projects', type: 'grid', columns: ['name'] };
|
|
99
|
-
const { c } = clientAnswering(view);
|
|
100
|
-
expect(await c.get('view', 'all_projects')).toEqual(view);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('A6: an `item` key WITHOUT the envelope identity is not unwrapped', async () => {
|
|
104
|
-
// Presence-checked against the spec shape, not duck-guessed off `item`
|
|
105
|
-
// alone — a body whose own schema has an `item` property stays whole.
|
|
106
|
-
const body = { name: 'cart', item: { sku: 'x' } };
|
|
107
|
-
const { c } = clientAnswering(body);
|
|
108
|
-
expect(await c.get('object', 'cart')).toEqual(body);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('A7: an array response passes through', async () => {
|
|
112
|
-
const { c } = clientAnswering([{ name: 'a' }]);
|
|
113
|
-
expect(await c.get('object', 'weird')).toEqual([{ name: 'a' }]);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('A8: 404 still reads as null', async () => {
|
|
117
|
-
const { c } = clientAnswering({}, { status: 404 });
|
|
118
|
-
expect(await c.get('object', 'missing')).toBeNull();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('A9: error responses still throw with status, code and message intact', async () => {
|
|
122
|
-
const { c } = clientAnswering(
|
|
123
|
-
{ error: { code: 'RESOURCE_FORBIDDEN', message: 'Nope.' } },
|
|
124
|
-
{ status: 403, headers: { 'content-type': 'application/json' } },
|
|
125
|
-
);
|
|
126
|
-
await expect(c.get('object', 'secret')).rejects.toThrow('Nope.');
|
|
127
|
-
const err = await c.get('object', 'secret').catch((e: MetadataError) => e);
|
|
128
|
-
expect((err as MetadataError).status).toBe(403);
|
|
129
|
-
expect((err as MetadataError).code).toBe('RESOURCE_FORBIDDEN');
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe('objectui#4271 · the getDraft() envelope asymmetry is preserved', () => {
|
|
134
|
-
it('A10: getDraft() returns the RAW envelope, as its docblock promises', async () => {
|
|
135
|
-
// Load-bearing: ~11 call sites read `.item` off getDraft (StudioDesignSurface,
|
|
136
|
-
// ResourceEditPage, PackageOwdOverviewPanel, ObjectHooksPanel,
|
|
137
|
-
// PermissionMatrixEditor). Routing getDraft through the unwrapping get()
|
|
138
|
-
// would silently empty every one of them.
|
|
139
|
-
const draftEnvelope = { type: 'object', name: 'showcase_project', item: OBJECT_BODY };
|
|
140
|
-
const { c, urls } = clientAnswering(draftEnvelope);
|
|
141
|
-
const draft = await c.getDraft<Record<string, unknown>>('object', 'showcase_project');
|
|
142
|
-
expect(draft).toEqual(draftEnvelope);
|
|
143
|
-
expect((draft as any)?.item).toEqual(OBJECT_BODY);
|
|
144
|
-
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?state=draft');
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('A11: get(state:draft) unwraps like any other get()', async () => {
|
|
148
|
-
// The SHAPE follows the method's published contract, not the query param:
|
|
149
|
-
// `get()` promises the body on every path it serves.
|
|
150
|
-
const { c, urls } = clientAnswering(ENVELOPE);
|
|
151
|
-
expect(await c.get('object', 'showcase_project', { state: 'draft' })).toEqual(OBJECT_BODY);
|
|
152
|
-
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?state=draft');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('A12: URL construction is untouched by the unwrap', async () => {
|
|
156
|
-
const { c, urls } = clientAnswering(ENVELOPE);
|
|
157
|
-
await c.get('object', 'has spaces', { packageId: 'app.crm' });
|
|
158
|
-
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/has%20spaces?package=app.crm');
|
|
159
|
-
const preview = new MetadataClient({
|
|
160
|
-
baseUrl: 'http://localhost:3000',
|
|
161
|
-
previewDrafts: true,
|
|
162
|
-
fetch: mockFetch(async (url) => {
|
|
163
|
-
urls.push(url);
|
|
164
|
-
return jsonResponse(ENVELOPE);
|
|
165
|
-
}),
|
|
166
|
-
});
|
|
167
|
-
// The overlay flag still rides, and the preview read unwraps too.
|
|
168
|
-
expect(await preview.get('object', 'showcase_project')).toEqual(OBJECT_BODY);
|
|
169
|
-
expect(urls[1]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?preview=draft');
|
|
170
|
-
});
|
|
171
|
-
});
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* `MetadataClient.save` must surface the runtime authoring gate's advisory
|
|
11
|
-
* findings (objectui#4133; backend objectstack#7435, landed as `89d7b35a7`).
|
|
12
|
-
*
|
|
13
|
-
* The save SUCCEEDED — a 200, the row persisted, the version bumped. The gate
|
|
14
|
-
* simply noticed something worth telling the author, and before this the
|
|
15
|
-
* findings were parsed off the wire and dropped on the floor client-side. These
|
|
16
|
-
* pins are the red-first evidence: with the emit removed from `save()`, the
|
|
17
|
-
* "renders the findings" cases below fail because no event ever arrives.
|
|
18
|
-
*
|
|
19
|
-
* The measured example the card is built on: a `nightly_purge` flow whose only
|
|
20
|
-
* defect is a `delete_record` node with `multi: true` and no filter yields
|
|
21
|
-
* `errors = 0 / advisories = 1` — a 200 the author currently learns nothing from.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
25
|
-
import {
|
|
26
|
-
MetadataClient,
|
|
27
|
-
readSaveAdvisories,
|
|
28
|
-
type MetadataSaveAdvisoryEvent,
|
|
29
|
-
type RuntimeAuthoringIssue,
|
|
30
|
-
} from './metadata-client';
|
|
31
|
-
|
|
32
|
-
/** The measured `nightly_purge` finding, in the spec's D3 shape. */
|
|
33
|
-
const PURGE_ADVISORY: RuntimeAuthoringIssue = {
|
|
34
|
-
severity: 'warning',
|
|
35
|
-
rule: 'flow/delete-without-filter',
|
|
36
|
-
where: 'flow "nightly_purge" · node "purge old rows"',
|
|
37
|
-
path: 'flows[0].nodes[2].config.filters',
|
|
38
|
-
message: 'this delete_record node sets multi: true with no filter, so it deletes every row',
|
|
39
|
-
hint: 'add a filter, or set multi: false to delete a single record',
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
function saveResponse(body: unknown): Response {
|
|
43
|
-
return new Response(JSON.stringify(body), {
|
|
44
|
-
status: 200,
|
|
45
|
-
headers: { 'content-type': 'application/json' },
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function clientWith(
|
|
50
|
-
responseBody: unknown,
|
|
51
|
-
onSaveAdvisory?: (ev: MetadataSaveAdvisoryEvent) => void,
|
|
52
|
-
) {
|
|
53
|
-
return new MetadataClient({
|
|
54
|
-
baseUrl: 'http://test.local',
|
|
55
|
-
fetch: vi.fn(async () => saveResponse(responseBody)) as unknown as typeof fetch,
|
|
56
|
-
...(onSaveAdvisory ? { onSaveAdvisory } : {}),
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const CLEAN_BODY = { success: true, version: 'v2', seq: 4, state: 'active' };
|
|
61
|
-
|
|
62
|
-
describe('MetadataClient.save — runtime authoring gate advisories (#4133)', () => {
|
|
63
|
-
it('emits the findings a successful save returned', async () => {
|
|
64
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
65
|
-
const client = clientWith(
|
|
66
|
-
{ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] },
|
|
67
|
-
(e) => events.push(e),
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
await client.save('flow', 'nightly_purge', { name: 'nightly_purge' });
|
|
71
|
-
|
|
72
|
-
expect(events).toHaveLength(1);
|
|
73
|
-
expect(events[0]).toEqual({
|
|
74
|
-
type: 'flow',
|
|
75
|
-
name: 'nightly_purge',
|
|
76
|
-
mode: 'publish',
|
|
77
|
-
advisories: [PURGE_ADVISORY],
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('carries rule, message and hint through verbatim — they are server prose', async () => {
|
|
82
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
83
|
-
const client = clientWith(
|
|
84
|
-
{ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] },
|
|
85
|
-
(e) => events.push(e),
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
await client.save('flow', 'nightly_purge', {});
|
|
89
|
-
|
|
90
|
-
const [finding] = events[0]!.advisories;
|
|
91
|
-
expect(finding!.rule).toBe('flow/delete-without-filter');
|
|
92
|
-
expect(finding!.message).toBe(PURGE_ADVISORY.message);
|
|
93
|
-
expect(finding!.hint).toBe(PURGE_ADVISORY.hint);
|
|
94
|
-
// Never `error` on this channel — an error-severity finding is a 422.
|
|
95
|
-
expect(finding!.severity).toBe('warning');
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('says nothing on a clean save — the server omits the key entirely', async () => {
|
|
99
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
100
|
-
const client = clientWith(CLEAN_BODY, (e) => events.push(e));
|
|
101
|
-
|
|
102
|
-
await client.save('object', 'account', { name: 'account' });
|
|
103
|
-
|
|
104
|
-
expect(events).toEqual([]);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('says nothing when the array is present but empty', async () => {
|
|
108
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
109
|
-
const client = clientWith({ ...CLEAN_BODY, advisories: [] }, (e) => events.push(e));
|
|
110
|
-
|
|
111
|
-
await client.save('object', 'account', {});
|
|
112
|
-
|
|
113
|
-
expect(events).toEqual([]);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('still returns the save response unchanged', async () => {
|
|
117
|
-
const body = { ...CLEAN_BODY, advisories: [PURGE_ADVISORY] };
|
|
118
|
-
const client = clientWith(body, () => {});
|
|
119
|
-
|
|
120
|
-
const result = await client.save('flow', 'nightly_purge', {});
|
|
121
|
-
|
|
122
|
-
expect(result).toEqual(body);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('a throwing sink never fails a save the server already committed', async () => {
|
|
126
|
-
const client = clientWith({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }, () => {
|
|
127
|
-
throw new Error('renderer exploded');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
await expect(client.save('flow', 'nightly_purge', {})).resolves.toBeTruthy();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* The honest control for the coverage statement (#4133 requirement 3).
|
|
135
|
-
*
|
|
136
|
-
* A `mode: 'draft'` save is a PUT through the same save door, but the
|
|
137
|
-
* framework's gate returns at its D1 early-return before running a single
|
|
138
|
-
* rule — `runtime-authoring-gate.ts`:
|
|
139
|
-
*
|
|
140
|
-
* // D1 — drafts are never gated. Publishing one runs this same function.
|
|
141
|
-
* if (args.state !== 'active') return null;
|
|
142
|
-
*
|
|
143
|
-
* so the server produces no findings and the response carries no key. The
|
|
144
|
-
* client is not what suppresses this; there is nothing to suppress. Pinned
|
|
145
|
-
* because Studio's designer saves as draft on EVERY edit, which is why the
|
|
146
|
-
* affordance renders nothing on that flow today.
|
|
147
|
-
*/
|
|
148
|
-
it('a draft save carries no findings — the gate never ran (D1)', async () => {
|
|
149
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
150
|
-
const client = clientWith({ ...CLEAN_BODY, state: 'draft' }, (e) => events.push(e));
|
|
151
|
-
|
|
152
|
-
await client.save('flow', 'nightly_purge', {}, { mode: 'draft' });
|
|
153
|
-
|
|
154
|
-
expect(events).toEqual([]);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('labels the mode when a draft save does somehow advise', async () => {
|
|
158
|
-
// Not reachable through today's server (see D1 above), but the event's
|
|
159
|
-
// `mode` must tell the truth about which door it came through rather than
|
|
160
|
-
// hard-coding one, so the field is pinned on its own.
|
|
161
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
162
|
-
const client = clientWith(
|
|
163
|
-
{ ...CLEAN_BODY, state: 'draft', advisories: [PURGE_ADVISORY] },
|
|
164
|
-
(e) => events.push(e),
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
await client.save('flow', 'nightly_purge', {}, { mode: 'draft' });
|
|
168
|
-
|
|
169
|
-
expect(events[0]!.mode).toBe('draft');
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('survives the withEnvironment clone — console clients are all env-scoped', async () => {
|
|
173
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
174
|
-
const base = new MetadataClient({
|
|
175
|
-
baseUrl: 'http://test.local',
|
|
176
|
-
fetch: vi.fn(async () =>
|
|
177
|
-
saveResponse({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }),
|
|
178
|
-
) as unknown as typeof fetch,
|
|
179
|
-
onSaveAdvisory: (e) => events.push(e),
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
await base.withEnvironment('env_1').save('flow', 'nightly_purge', {});
|
|
183
|
-
|
|
184
|
-
expect(events).toHaveLength(1);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('survives the withPreviewDrafts clone', async () => {
|
|
188
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
189
|
-
const base = new MetadataClient({
|
|
190
|
-
baseUrl: 'http://test.local',
|
|
191
|
-
fetch: vi.fn(async () =>
|
|
192
|
-
saveResponse({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }),
|
|
193
|
-
) as unknown as typeof fetch,
|
|
194
|
-
onSaveAdvisory: (e) => events.push(e),
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
await base.withPreviewDrafts(true).save('flow', 'nightly_purge', {});
|
|
198
|
-
|
|
199
|
-
expect(events).toHaveLength(1);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe('readSaveAdvisories', () => {
|
|
204
|
-
it('reads a well-formed array', () => {
|
|
205
|
-
expect(readSaveAdvisories({ advisories: [PURGE_ADVISORY] })).toEqual([PURGE_ADVISORY]);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('returns empty for a response with no advisories key', () => {
|
|
209
|
-
expect(readSaveAdvisories(CLEAN_BODY)).toEqual([]);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it.each([
|
|
213
|
-
['null body', null],
|
|
214
|
-
['a string body', 'nope'],
|
|
215
|
-
['a non-array advisories', { advisories: { rule: 'x' } }],
|
|
216
|
-
])('returns empty for %s', (_label, body) => {
|
|
217
|
-
expect(readSaveAdvisories(body)).toEqual([]);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('drops half-shaped findings rather than rendering blanks at the author', () => {
|
|
221
|
-
const out = readSaveAdvisories({
|
|
222
|
-
advisories: [PURGE_ADVISORY, { rule: 'only-a-rule' }, null, 'string'],
|
|
223
|
-
});
|
|
224
|
-
expect(out).toEqual([PURGE_ADVISORY]);
|
|
225
|
-
});
|
|
226
|
-
});
|