@object-ui/data-objectstack 17.5.0 → 17.7.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 +2311 -0
- package/README.md +557 -11
- package/dist/index.cjs +1663 -463
- package/dist/index.d.cts +1780 -518
- package/dist/index.d.ts +1780 -518
- package/dist/index.js +1648 -457
- package/package.json +9 -9
- 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,233 +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#4408 — an `enable`-block denial must not arrive at the surface as
|
|
11
|
-
* "no data".
|
|
12
|
-
*
|
|
13
|
-
* `find()` degraded EVERY 404 into `{ data: [], total: 0 }` and memoised the
|
|
14
|
-
* resource in `missingResources`, so a list pointed at an object the server
|
|
15
|
-
* refuses to expose (`enable.apiEnabled: false` → HTTP 404 +
|
|
16
|
-
* `code: 'OBJECT_API_DISABLED'`) resolved successfully with zero rows. A
|
|
17
|
-
* resolved promise with zero rows is indistinguishable from a genuinely empty
|
|
18
|
-
* object, so the list rendered its ordinary empty state: "you have no records"
|
|
19
|
-
* over a page that can never hold any. The reported instance (Setup › Advanced
|
|
20
|
-
* › Signing Keys) could not load for any persona and said so to nobody — which
|
|
21
|
-
* is also how the upstream defect objectstack#7544 survived review for its
|
|
22
|
-
* whole life.
|
|
23
|
-
*
|
|
24
|
-
* The discrimination is on the ADR-0112 `code`, never the status: a 404 from a
|
|
25
|
-
* missing collection and a 404 from a disabled object are the same status, and
|
|
26
|
-
* the optional-collection probes below depend on the missing-collection half
|
|
27
|
-
* still degrading to empty. Both halves are asserted here, in both directions.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
31
|
-
import { ObjectStackAdapter, isApiAccessDeniedError, API_ACCESS_DENIED_CODES } from './index';
|
|
32
|
-
|
|
33
|
-
/** An error shaped the way `@objectstack/client`'s fetch wrapper throws one. */
|
|
34
|
-
function clientError(message: string, httpStatus: number, code?: string) {
|
|
35
|
-
const err = new Error(message) as Error & { httpStatus: number; code?: string };
|
|
36
|
-
err.httpStatus = httpStatus;
|
|
37
|
-
if (code) err.code = code;
|
|
38
|
-
return err;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function makeDS(find: any) {
|
|
42
|
-
const ds: any = new ObjectStackAdapter({
|
|
43
|
-
baseUrl: 'http://test.local',
|
|
44
|
-
fetch: vi.fn(async () =>
|
|
45
|
-
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
46
|
-
status: 200,
|
|
47
|
-
headers: { 'Content-Type': 'application/json' },
|
|
48
|
-
})),
|
|
49
|
-
});
|
|
50
|
-
ds.connected = true;
|
|
51
|
-
ds.connectionState = 'connected';
|
|
52
|
-
ds.client = { data: { find } };
|
|
53
|
-
return ds;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
describe('isApiAccessDeniedError', () => {
|
|
57
|
-
it('matches both enable-block denial codes', () => {
|
|
58
|
-
expect(isApiAccessDeniedError({ code: 'OBJECT_API_DISABLED' })).toBe(true);
|
|
59
|
-
expect(isApiAccessDeniedError({ code: 'OBJECT_API_METHOD_NOT_ALLOWED' })).toBe(true);
|
|
60
|
-
expect([...API_ACCESS_DENIED_CODES]).toEqual([
|
|
61
|
-
'OBJECT_API_DISABLED',
|
|
62
|
-
'OBJECT_API_METHOD_NOT_ALLOWED',
|
|
63
|
-
]);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('matches case-insensitively (the pre-ADR-0112 spelling still resolves)', () => {
|
|
67
|
-
expect(isApiAccessDeniedError({ code: 'object_api_disabled' })).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('does NOT match the codes a missing collection or record answers with', () => {
|
|
71
|
-
expect(isApiAccessDeniedError({ code: 'OBJECT_NOT_FOUND' })).toBe(false);
|
|
72
|
-
expect(isApiAccessDeniedError({ code: 'RECORD_NOT_FOUND' })).toBe(false);
|
|
73
|
-
expect(isApiAccessDeniedError({ code: 'PERMISSION_DENIED' })).toBe(false);
|
|
74
|
-
// A bare 404 carries no code at all — status alone must never qualify.
|
|
75
|
-
expect(isApiAccessDeniedError({ httpStatus: 404 })).toBe(false);
|
|
76
|
-
expect(isApiAccessDeniedError(undefined)).toBe(false);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('ObjectStackDataSource.find — enable-block denials are surfaced, not emptied', () => {
|
|
81
|
-
// ── The defect pin ────────────────────────────────────────────────────────
|
|
82
|
-
it('REJECTS a 404 OBJECT_API_DISABLED instead of resolving to zero rows', async () => {
|
|
83
|
-
const find = vi.fn().mockRejectedValue(
|
|
84
|
-
clientError('Object API is disabled', 404, 'OBJECT_API_DISABLED'),
|
|
85
|
-
);
|
|
86
|
-
const ds = makeDS(find);
|
|
87
|
-
|
|
88
|
-
await expect(ds.find('sys_jwks')).rejects.toMatchObject({
|
|
89
|
-
code: 'OBJECT_API_DISABLED',
|
|
90
|
-
httpStatus: 404,
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('REJECTS the 405 sibling and keeps its code intact for the surface', async () => {
|
|
95
|
-
const find = vi.fn().mockRejectedValue(
|
|
96
|
-
clientError('Method not allowed', 405, 'OBJECT_API_METHOD_NOT_ALLOWED'),
|
|
97
|
-
);
|
|
98
|
-
const ds = makeDS(find);
|
|
99
|
-
|
|
100
|
-
await expect(ds.find('sys_jwks')).rejects.toMatchObject({
|
|
101
|
-
code: 'OBJECT_API_METHOD_NOT_ALLOWED',
|
|
102
|
-
httpStatus: 405,
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('does not memoise a denial — the second call still asks and still rejects', async () => {
|
|
107
|
-
// `missingResources` short-circuits later calls to `{ data: [], total: 0 }`
|
|
108
|
-
// WITHOUT touching the network. Absorbing a denial into it would pin the
|
|
109
|
-
// object to "empty" for the rest of the session, so the honest state would
|
|
110
|
-
// appear once and then silently revert to the masking empty state.
|
|
111
|
-
const find = vi.fn().mockRejectedValue(
|
|
112
|
-
clientError('Object API is disabled', 404, 'OBJECT_API_DISABLED'),
|
|
113
|
-
);
|
|
114
|
-
const ds = makeDS(find);
|
|
115
|
-
|
|
116
|
-
await expect(ds.find('sys_jwks')).rejects.toThrow();
|
|
117
|
-
await expect(ds.find('sys_jwks')).rejects.toMatchObject({ code: 'OBJECT_API_DISABLED' });
|
|
118
|
-
expect(find).toHaveBeenCalledTimes(2);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// ── The controls: must stay green on BOTH sides of the fix ────────────────
|
|
122
|
-
it('CONTROL: a bare 404 (collection absent on this backend) still degrades to empty', async () => {
|
|
123
|
-
// The optional-collection probes (AppHeader's sys_presence/sys_activity …)
|
|
124
|
-
// read empty data as "feature unavailable". Turning those into thrown
|
|
125
|
-
// errors would be a worse bug than the one being fixed.
|
|
126
|
-
const find = vi.fn().mockRejectedValue(clientError('Not found', 404));
|
|
127
|
-
const ds = makeDS(find);
|
|
128
|
-
|
|
129
|
-
await expect(ds.find('sys_presence')).resolves.toMatchObject({ data: [], total: 0 });
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('CONTROL: a 404 OBJECT_NOT_FOUND still degrades to empty, and is still memoised', async () => {
|
|
133
|
-
const find = vi.fn().mockRejectedValue(clientError('No such object', 404, 'OBJECT_NOT_FOUND'));
|
|
134
|
-
const ds = makeDS(find);
|
|
135
|
-
|
|
136
|
-
await expect(ds.find('sys_activity')).resolves.toMatchObject({ data: [], total: 0 });
|
|
137
|
-
// Memoised: the second call short-circuits without asking again.
|
|
138
|
-
await expect(ds.find('sys_activity')).resolves.toMatchObject({ data: [], total: 0 });
|
|
139
|
-
expect(find).toHaveBeenCalledTimes(1);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('CONTROL: a 404 RECORD_NOT_FOUND is not an enable-block denial', async () => {
|
|
143
|
-
const find = vi.fn().mockRejectedValue(clientError('No such record', 404, 'RECORD_NOT_FOUND'));
|
|
144
|
-
const ds = makeDS(find);
|
|
145
|
-
|
|
146
|
-
await expect(ds.find('account')).resolves.toMatchObject({ data: [], total: 0 });
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('CONTROL: a 2xx with zero rows resolves as an ordinary empty result', async () => {
|
|
150
|
-
// The overwhelmingly common case, and the one the empty state exists for.
|
|
151
|
-
// A fix that turned "no records yet" into an error surface would be worse
|
|
152
|
-
// than the bug.
|
|
153
|
-
const find = vi.fn().mockResolvedValue({ records: [], total: 0 });
|
|
154
|
-
const ds = makeDS(find);
|
|
155
|
-
|
|
156
|
-
await expect(ds.find('account')).resolves.toMatchObject({ data: [], total: 0 });
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('CONTROL: a 2xx with rows is untouched', async () => {
|
|
160
|
-
const find = vi.fn().mockResolvedValue({ records: [{ id: '1' }], total: 1 });
|
|
161
|
-
const ds = makeDS(find);
|
|
162
|
-
|
|
163
|
-
await expect(ds.find('account')).resolves.toMatchObject({ data: [{ id: '1' }], total: 1 });
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('CONTROL: a 403 still propagates (it was never swallowed)', async () => {
|
|
167
|
-
const find = vi.fn().mockRejectedValue(clientError('Forbidden', 403, 'PERMISSION_DENIED'));
|
|
168
|
-
const ds = makeDS(find);
|
|
169
|
-
|
|
170
|
-
await expect(ds.find('account')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* The `$expand` / `$search` branch of `find()` bypasses `@objectstack/client`
|
|
176
|
-
* and hand-rolls its own fetch, so it also has to stamp the ADR-0112 envelope
|
|
177
|
-
* itself. It used to set only `status`, which left the surface with a bare 404
|
|
178
|
-
* or 405 and nothing to discriminate on — the same denial, arriving anonymous,
|
|
179
|
-
* on the path a list takes whenever it expands a lookup or runs a search.
|
|
180
|
-
*/
|
|
181
|
-
describe('ObjectStackDataSource.find — the raw $expand/$search branch carries the code', () => {
|
|
182
|
-
function makeRawDS(status: number, body: unknown) {
|
|
183
|
-
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
|
|
184
|
-
if (String(url).includes('/data/')) {
|
|
185
|
-
return new Response(JSON.stringify(body), {
|
|
186
|
-
status,
|
|
187
|
-
headers: { 'Content-Type': 'application/json' },
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
return new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
191
|
-
status: 200,
|
|
192
|
-
headers: { 'Content-Type': 'application/json' },
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
196
|
-
ds.connected = true;
|
|
197
|
-
ds.connectionState = 'connected';
|
|
198
|
-
return ds;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
it('propagates a top-level `code` from the error body', async () => {
|
|
202
|
-
const ds = makeRawDS(404, { code: 'OBJECT_API_DISABLED', message: 'Object API is disabled' });
|
|
203
|
-
|
|
204
|
-
await expect(ds.find('sys_jwks', { $search: 'anything' })).rejects.toMatchObject({
|
|
205
|
-
code: 'OBJECT_API_DISABLED',
|
|
206
|
-
status: 404,
|
|
207
|
-
httpStatus: 404,
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('propagates a nested `error.code` envelope, like the client wrapper does', async () => {
|
|
212
|
-
const ds = makeRawDS(405, {
|
|
213
|
-
error: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', message: 'Method not allowed' },
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
await expect(ds.find('sys_jwks', { $expand: ['owner'] })).rejects.toMatchObject({
|
|
217
|
-
code: 'OBJECT_API_METHOD_NOT_ALLOWED',
|
|
218
|
-
httpStatus: 405,
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it('CONTROL: a body with no code leaves `code` undefined rather than inventing one', async () => {
|
|
223
|
-
const ds = makeRawDS(404, { message: 'Not found' });
|
|
224
|
-
|
|
225
|
-
await expect(ds.find('sys_jwks', { $expand: ['owner'] })).rejects.toMatchObject({
|
|
226
|
-
status: 404,
|
|
227
|
-
});
|
|
228
|
-
await ds.find('sys_jwks', { $expand: ['owner'] }).catch((e: any) => {
|
|
229
|
-
expect(e.code).toBeUndefined();
|
|
230
|
-
expect(isApiAccessDeniedError(e)).toBe(false);
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
});
|
|
@@ -1,147 +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#4252 — an app the session may not open must be distinguishable from
|
|
11
|
-
* an app that is not there.
|
|
12
|
-
*
|
|
13
|
-
* `GET /api/v1/meta/apps` is filtered per session server-side
|
|
14
|
-
* (`filterAppForUser`), so those two conditions are byte-identical in the list:
|
|
15
|
-
* both are simply absent. The maintainer ruling (2026-08-12) put the
|
|
16
|
-
* distinction on the BY-NAME route instead of flagging the list — the
|
|
17
|
-
* enumeration surface is not widened past what a by-name probe already implies
|
|
18
|
-
* — and objectstack#8013 (PR #8135) shipped it:
|
|
19
|
-
*
|
|
20
|
-
* - EXISTS + session lacks `requiredPermissions` → `403`
|
|
21
|
-
* `{ success: false, error: { code: 'PERMISSION_DENIED', message } }`
|
|
22
|
-
* - a nonexistent name, an unpublished app (ADR-0045 §3 keeps it externally
|
|
23
|
-
* unobservable), an app gated by an absent optional service (ADR-0057 D10 —
|
|
24
|
-
* nothing was denied to the CALLER) → `404 RESOURCE_NOT_FOUND`, unchanged.
|
|
25
|
-
*
|
|
26
|
-
* Every case here goes through the real `ObjectStackClient` over a stubbed
|
|
27
|
-
* `fetch`, so what is measured includes the client's own error stamping
|
|
28
|
-
* (`error.code` off `body.error.code`, `error.httpStatus` off the status) —
|
|
29
|
-
* the seam a hand-built rejection would have skipped straight past.
|
|
30
|
-
*
|
|
31
|
-
* The discrimination is on the ADR-0112 CODE, never the status (objectui#4408),
|
|
32
|
-
* and the last two cases are what hold that: they cross the two apart, so a
|
|
33
|
-
* reimplementation that reads `httpStatus === 403` goes red in both directions
|
|
34
|
-
* rather than passing on the happy path.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
38
|
-
import {
|
|
39
|
-
ObjectStackAdapter,
|
|
40
|
-
isAppPermissionDeniedError,
|
|
41
|
-
APP_PERMISSION_DENIED_CODE,
|
|
42
|
-
} from './index';
|
|
43
|
-
|
|
44
|
-
const json = (status: number, body: unknown) =>
|
|
45
|
-
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
46
|
-
|
|
47
|
-
/** The 403 body objectstack#8135 emits, transcribed from that merged diff. */
|
|
48
|
-
const DENIED_BODY = {
|
|
49
|
-
success: false,
|
|
50
|
-
error: {
|
|
51
|
-
code: 'PERMISSION_DENIED',
|
|
52
|
-
message: "You do not have permission to open the 'finance' app.",
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/** The absence body the same route keeps answering for every other refusal. */
|
|
57
|
-
const ABSENT_BODY = {
|
|
58
|
-
error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' },
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
function makeAdapter(answer: (url: string) => Response) {
|
|
62
|
-
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => answer(String(input)));
|
|
63
|
-
const adapter = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
64
|
-
return { adapter, fetchImpl };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe('isAppPermissionDeniedError', () => {
|
|
68
|
-
it('matches the by-name route\'s denial code, in either ADR-0112 spelling', () => {
|
|
69
|
-
expect(APP_PERMISSION_DENIED_CODE).toBe('PERMISSION_DENIED');
|
|
70
|
-
expect(isAppPermissionDeniedError({ code: 'PERMISSION_DENIED' })).toBe(true);
|
|
71
|
-
// The console is versioned separately from the server it talks to; the
|
|
72
|
-
// pre-ADR-0112 lowercase spelling still resolves (`errorCodeIs`).
|
|
73
|
-
expect(isAppPermissionDeniedError({ code: 'permission_denied' })).toBe(true);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('does NOT match absence, the enable-block denials, or a bare status', () => {
|
|
77
|
-
expect(isAppPermissionDeniedError({ code: 'RESOURCE_NOT_FOUND' })).toBe(false);
|
|
78
|
-
// `API_ACCESS_DENIED_CODES` (objectui#4408) is a different question: those
|
|
79
|
-
// are pure functions of an object's `enable` metadata and identical for
|
|
80
|
-
// every persona. This one is a statement about the caller.
|
|
81
|
-
expect(isAppPermissionDeniedError({ code: 'OBJECT_API_DISABLED' })).toBe(false);
|
|
82
|
-
expect(isAppPermissionDeniedError({ httpStatus: 403 })).toBe(false);
|
|
83
|
-
expect(isAppPermissionDeniedError(undefined)).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe('ObjectStackAdapter.probeAppAccess — over the wire', () => {
|
|
88
|
-
it('reports `denied` for the 403 PERMISSION_DENIED envelope', async () => {
|
|
89
|
-
const { adapter, fetchImpl } = makeAdapter(() => json(403, DENIED_BODY));
|
|
90
|
-
|
|
91
|
-
await expect(adapter.probeAppAccess('finance')).resolves.toBe('denied');
|
|
92
|
-
// The by-name address, singular — what objectstack#8013 pinned its cases
|
|
93
|
-
// against and what `MetadataProvider` already reads items by.
|
|
94
|
-
expect(String(fetchImpl.mock.calls[0][0])).toContain('/api/v1/meta/app/finance');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('reports `unknown` for the 404 absence envelope — the copy must not move', async () => {
|
|
98
|
-
const { adapter } = makeAdapter(() => json(404, ABSENT_BODY));
|
|
99
|
-
await expect(adapter.probeAppAccess('no_such_app')).resolves.toBe('unknown');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('reports `granted` when the route serves the app', async () => {
|
|
103
|
-
const { adapter } = makeAdapter(() =>
|
|
104
|
-
json(200, { type: 'app', name: 'finance', item: { name: 'finance', label: 'Finance' } }),
|
|
105
|
-
);
|
|
106
|
-
await expect(adapter.probeAppAccess('finance')).resolves.toBe('granted');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('reports `unknown` — never `denied` — when the server cannot be reached', async () => {
|
|
110
|
-
const { adapter } = makeAdapter(() => {
|
|
111
|
-
throw new Error('network down');
|
|
112
|
-
});
|
|
113
|
-
await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('reports `unknown` for an empty name without asking anything', async () => {
|
|
117
|
-
const { adapter, fetchImpl } = makeAdapter(() => json(200, {}));
|
|
118
|
-
await expect(adapter.probeAppAccess('')).resolves.toBe('unknown');
|
|
119
|
-
expect(fetchImpl).not.toHaveBeenCalled();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('never throws — a caller renders a screen off this, not a catch block', async () => {
|
|
123
|
-
const { adapter } = makeAdapter(() => new Response('<html>gateway</html>', { status: 502 }));
|
|
124
|
-
await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown');
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
// ── code, not status ─────────────────────────────────────────────────────
|
|
128
|
-
//
|
|
129
|
-
// Both directions, because a status-reading implementation passes every case
|
|
130
|
-
// above. 403 is not the fact; `PERMISSION_DENIED` is.
|
|
131
|
-
|
|
132
|
-
it('a 403 WITHOUT the code is `unknown` — a status alone never denies', async () => {
|
|
133
|
-
const { adapter } = makeAdapter(() =>
|
|
134
|
-
json(403, { error: { code: 'CSRF_TOKEN_INVALID', message: 'stale token' } }),
|
|
135
|
-
);
|
|
136
|
-
await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown');
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('the code decides even when the status is not 403', async () => {
|
|
140
|
-
// Hypothetical on today's server, and deliberately so: the contract this
|
|
141
|
-
// console consumes is the code. If the route ever answers the same denial
|
|
142
|
-
// under a different status, the branch must follow the code — and if this
|
|
143
|
-
// case is ever deleted, the reason must be that the CODE changed.
|
|
144
|
-
const { adapter } = makeAdapter(() => json(401, DENIED_BODY));
|
|
145
|
-
await expect(adapter.probeAppAccess('finance')).resolves.toBe('denied');
|
|
146
|
-
});
|
|
147
|
-
});
|
package/src/bulkDelete.test.ts
DELETED
|
@@ -1,75 +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
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
-
import { ObjectStackAdapter } from './index';
|
|
11
|
-
|
|
12
|
-
function makeDS(stub: { deleteMany?: any; delete?: any }) {
|
|
13
|
-
const ds: any = new ObjectStackAdapter({
|
|
14
|
-
baseUrl: 'http://test.local',
|
|
15
|
-
fetch: vi.fn(async () => {
|
|
16
|
-
const body = { success: true, data: { capabilities: {}, routes: {} } };
|
|
17
|
-
return new Response(JSON.stringify(body), {
|
|
18
|
-
status: 200,
|
|
19
|
-
headers: { 'Content-Type': 'application/json' },
|
|
20
|
-
});
|
|
21
|
-
}),
|
|
22
|
-
});
|
|
23
|
-
ds.connected = true;
|
|
24
|
-
ds.connectionState = 'connected';
|
|
25
|
-
ds.client = { data: stub };
|
|
26
|
-
return ds;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe('ObjectStackDataSource.bulkDelete', () => {
|
|
30
|
-
it('returns 0 and skips network when ids is empty', async () => {
|
|
31
|
-
const deleteMany = vi.fn();
|
|
32
|
-
const ds = makeDS({ deleteMany });
|
|
33
|
-
const n = await ds.bulkDelete('sys_notification', []);
|
|
34
|
-
expect(n).toBe(0);
|
|
35
|
-
expect(deleteMany).not.toHaveBeenCalled();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('issues a single deleteMany call with the id list', async () => {
|
|
39
|
-
const deleteMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0 });
|
|
40
|
-
const ds = makeDS({ deleteMany });
|
|
41
|
-
const n = await ds.bulkDelete('task', ['a', 'b', 'c']);
|
|
42
|
-
expect(n).toBe(3);
|
|
43
|
-
expect(deleteMany).toHaveBeenCalledTimes(1);
|
|
44
|
-
const [resource, ids, opts] = deleteMany.mock.calls[0];
|
|
45
|
-
expect(resource).toBe('task');
|
|
46
|
-
expect(ids).toEqual(['a', 'b', 'c']);
|
|
47
|
-
expect(opts).toEqual({ continueOnError: true });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('returns ids.length when deleteMany returns void (older clients)', async () => {
|
|
51
|
-
const deleteMany = vi.fn().mockResolvedValue(undefined);
|
|
52
|
-
const ds = makeDS({ deleteMany });
|
|
53
|
-
const n = await ds.bulkDelete('task', ['a', 'b']);
|
|
54
|
-
expect(n).toBe(2);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('falls back to per-id delete loop when deleteMany is missing', async () => {
|
|
58
|
-
const del = vi.fn(async (_: string, id: string) => {
|
|
59
|
-
if (id === 'b') throw new Error('FK violation');
|
|
60
|
-
});
|
|
61
|
-
const ds = makeDS({ delete: del });
|
|
62
|
-
const n = await ds.bulkDelete('task', ['a', 'b', 'c']);
|
|
63
|
-
// continueOnError-emulated: b throws, a + c succeed.
|
|
64
|
-
expect(n).toBe(2);
|
|
65
|
-
expect(del).toHaveBeenCalledTimes(3);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('coerces non-string ids before sending', async () => {
|
|
69
|
-
const deleteMany = vi.fn().mockResolvedValue({ succeeded: 2 });
|
|
70
|
-
const ds = makeDS({ deleteMany });
|
|
71
|
-
await ds.bulkDelete('task', [1, 2]);
|
|
72
|
-
const [, ids] = deleteMany.mock.calls[0];
|
|
73
|
-
expect(ids).toEqual(['1', '2']);
|
|
74
|
-
});
|
|
75
|
-
});
|
package/src/bulkUpdate.test.ts
DELETED
|
@@ -1,93 +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
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
-
import { ObjectStackAdapter } from './index';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Build a DataSource instance with a stubbed `fetch` that satisfies the
|
|
14
|
-
* one-shot discovery probe inside `connect()`. The client object is then
|
|
15
|
-
* swapped out for a lightweight mock so we can assert directly against
|
|
16
|
-
* the `updateMany` / `update` calls without spinning real HTTP.
|
|
17
|
-
*/
|
|
18
|
-
function makeDS(stub: { updateMany?: any; update?: any }) {
|
|
19
|
-
const ds: any = new ObjectStackAdapter({
|
|
20
|
-
baseUrl: 'http://test.local',
|
|
21
|
-
fetch: vi.fn(async (_url: any, _init: any) => {
|
|
22
|
-
const body = { success: true, data: { capabilities: {}, routes: {} } };
|
|
23
|
-
return new Response(JSON.stringify(body), {
|
|
24
|
-
status: 200,
|
|
25
|
-
headers: { 'Content-Type': 'application/json' },
|
|
26
|
-
});
|
|
27
|
-
}),
|
|
28
|
-
});
|
|
29
|
-
// Pre-mark connected so connect() short-circuits without hitting fetch.
|
|
30
|
-
ds.connected = true;
|
|
31
|
-
ds.connectionState = 'connected';
|
|
32
|
-
ds.client = { data: stub };
|
|
33
|
-
return ds;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
describe('ObjectStackDataSource.bulkUpdate', () => {
|
|
37
|
-
it('returns 0 and skips network when ids is empty', async () => {
|
|
38
|
-
const updateMany = vi.fn();
|
|
39
|
-
const ds = makeDS({ updateMany });
|
|
40
|
-
const n = await ds.bulkUpdate('sys_notification', [], { is_read: true });
|
|
41
|
-
expect(n).toBe(0);
|
|
42
|
-
expect(updateMany).not.toHaveBeenCalled();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('issues a single updateMany call with the per-id record shape', async () => {
|
|
46
|
-
const updateMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0, results: [] });
|
|
47
|
-
const ds = makeDS({ updateMany });
|
|
48
|
-
const n = await ds.bulkUpdate('sys_notification', ['a', 'b', 'c'], {
|
|
49
|
-
is_read: true,
|
|
50
|
-
read_at: '2026-05-23T05:00:00.000Z',
|
|
51
|
-
});
|
|
52
|
-
expect(n).toBe(3);
|
|
53
|
-
expect(updateMany).toHaveBeenCalledTimes(1);
|
|
54
|
-
const [resource, records, opts] = updateMany.mock.calls[0];
|
|
55
|
-
expect(resource).toBe('sys_notification');
|
|
56
|
-
expect(opts).toEqual({ continueOnError: true });
|
|
57
|
-
expect(records).toEqual([
|
|
58
|
-
{ id: 'a', data: { is_read: true, read_at: '2026-05-23T05:00:00.000Z' } },
|
|
59
|
-
{ id: 'b', data: { is_read: true, read_at: '2026-05-23T05:00:00.000Z' } },
|
|
60
|
-
{ id: 'c', data: { is_read: true, read_at: '2026-05-23T05:00:00.000Z' } },
|
|
61
|
-
]);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('honors server-reported partial success counts', async () => {
|
|
65
|
-
const updateMany = vi.fn().mockResolvedValue({ succeeded: 2, failed: 1, results: [] });
|
|
66
|
-
const ds = makeDS({ updateMany });
|
|
67
|
-
const n = await ds.bulkUpdate('sys_notification', ['a', 'b', 'c'], { is_read: true });
|
|
68
|
-
expect(n).toBe(2);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('falls back to per-id updates when client.data.updateMany is missing', async () => {
|
|
72
|
-
const update = vi.fn().mockResolvedValue({ record: {} });
|
|
73
|
-
const ds = makeDS({ update });
|
|
74
|
-
const n = await ds.bulkUpdate('sys_notification', ['a', 'b'], { is_read: true });
|
|
75
|
-
expect(n).toBe(2);
|
|
76
|
-
expect(update).toHaveBeenCalledTimes(2);
|
|
77
|
-
expect(update.mock.calls[0][0]).toBe('sys_notification');
|
|
78
|
-
expect(update.mock.calls[0][1]).toBe('a');
|
|
79
|
-
expect(update.mock.calls[0][2]).toEqual({ is_read: true });
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('fallback path tolerates per-row failures (continueOnError semantics)', async () => {
|
|
83
|
-
const update = vi
|
|
84
|
-
.fn()
|
|
85
|
-
.mockResolvedValueOnce({ record: {} })
|
|
86
|
-
.mockRejectedValueOnce(new Error('row 2 failed'))
|
|
87
|
-
.mockResolvedValueOnce({ record: {} });
|
|
88
|
-
const ds = makeDS({ update });
|
|
89
|
-
const n = await ds.bulkUpdate('sys_notification', ['a', 'b', 'c'], { is_read: true });
|
|
90
|
-
expect(n).toBe(2);
|
|
91
|
-
expect(update).toHaveBeenCalledTimes(3);
|
|
92
|
-
});
|
|
93
|
-
});
|