@jskit-ai/crud-core 0.1.26 → 0.1.28
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/package.descriptor.mjs +2 -2
- package/package.json +17 -7
- package/src/client/composables/crudClientSupportHelpers.js +5 -17
- package/src/server/createCrudRepositoryFromResource.js +57 -0
- package/src/server/createCrudServiceFromResource.js +101 -0
- package/src/server/crudModuleConfig.js +13 -21
- package/src/server/fieldAccess.js +316 -0
- package/src/server/listQueryValidators.js +87 -0
- package/src/server/lookupHydration.js +546 -0
- package/src/server/lookupPathSupport.js +45 -0
- package/src/server/lookupProviders.js +43 -0
- package/src/server/repositoryMethods.js +381 -0
- package/src/server/repositorySupport.js +205 -0
- package/src/server/serviceEvents.js +53 -0
- package/src/shared/crudFieldMetaSupport.js +54 -0
- package/src/shared/crudNamespaceSupport.js +31 -0
- package/test/createCrudRepositoryFromResource.test.js +731 -0
- package/test/createCrudServiceFromResource.test.js +263 -0
- package/test/crudFieldMetaSupport.test.js +47 -0
- package/test/fieldAccess.test.js +86 -0
- package/test/listQueryValidators.test.js +162 -0
- package/test/lookupProviders.test.js +103 -0
- package/test/repositorySupport.test.js +282 -1
- package/test/serviceEvents.test.js +28 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createCrudServiceFromResource } from "../src/server/createCrudServiceFromResource.js";
|
|
4
|
+
|
|
5
|
+
function createResourceWithOutputSchema(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
resource: "contacts",
|
|
8
|
+
operations: {
|
|
9
|
+
view: {
|
|
10
|
+
outputValidator: {
|
|
11
|
+
schema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
id: { type: "integer" },
|
|
15
|
+
name: { type: "string" },
|
|
16
|
+
optionalSecret: { type: "string" },
|
|
17
|
+
nullableSecret: {
|
|
18
|
+
anyOf: [{ type: "string" }, { type: "null" }]
|
|
19
|
+
},
|
|
20
|
+
defaultedSecret: {
|
|
21
|
+
type: "string",
|
|
22
|
+
default: ""
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
required: ["id", "name", "nullableSecret", "defaultedSecret"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
...overrides
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createRepositoryDouble(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
async list(query) {
|
|
37
|
+
return { items: [query], nextCursor: null };
|
|
38
|
+
},
|
|
39
|
+
async findById(recordId) {
|
|
40
|
+
return recordId === 1 ? { id: 1 } : null;
|
|
41
|
+
},
|
|
42
|
+
async create(payload) {
|
|
43
|
+
return { id: 2, ...payload };
|
|
44
|
+
},
|
|
45
|
+
async updateById(recordId, payload) {
|
|
46
|
+
if (recordId !== 1) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return { id: 1, ...payload };
|
|
50
|
+
},
|
|
51
|
+
async deleteById(recordId) {
|
|
52
|
+
if (recordId !== 1) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return { id: 1, deleted: true };
|
|
56
|
+
},
|
|
57
|
+
...overrides
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("createCrudServiceFromResource builds default service events", () => {
|
|
62
|
+
const { baseServiceEvents } = createCrudServiceFromResource({
|
|
63
|
+
resource: "contacts"
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(baseServiceEvents.createRecord[0].realtime.event, "contacts.record.changed");
|
|
67
|
+
assert.equal(baseServiceEvents.updateRecord[0].realtime.event, "contacts.record.changed");
|
|
68
|
+
assert.equal(baseServiceEvents.deleteRecord[0].realtime.event, "contacts.record.changed");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("createCrudServiceFromResource normalizes namespace for realtime event names", () => {
|
|
72
|
+
const { baseServiceEvents } = createCrudServiceFromResource({
|
|
73
|
+
resource: "customer-orders"
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(baseServiceEvents.createRecord[0].realtime.event, "customer_orders.record.changed");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("createCrudServiceFromResource delegates service methods and applies 404 semantics", async () => {
|
|
80
|
+
const { createBaseService } = createCrudServiceFromResource({
|
|
81
|
+
resource: "contacts"
|
|
82
|
+
});
|
|
83
|
+
const service = createBaseService({
|
|
84
|
+
repository: createRepositoryDouble()
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const list = await service.listRecords({ limit: 2 }, {});
|
|
88
|
+
assert.deepEqual(list, {
|
|
89
|
+
items: [{ limit: 2 }],
|
|
90
|
+
nextCursor: null
|
|
91
|
+
});
|
|
92
|
+
assert.deepEqual(await service.getRecord(1, {}), { id: 1 });
|
|
93
|
+
await assert.rejects(
|
|
94
|
+
() => service.getRecord(9, {}),
|
|
95
|
+
(error) => error?.status === 404 && error?.message === "Record not found."
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(await service.createRecord({ name: "A" }, {}), { id: 2, name: "A" });
|
|
98
|
+
assert.deepEqual(await service.updateRecord(1, { name: "B" }, {}), { id: 1, name: "B" });
|
|
99
|
+
await assert.rejects(
|
|
100
|
+
() => service.updateRecord(9, { name: "B" }, {}),
|
|
101
|
+
(error) => error?.status === 404 && error?.message === "Record not found."
|
|
102
|
+
);
|
|
103
|
+
assert.deepEqual(await service.deleteRecord(1, {}), { id: 1, deleted: true });
|
|
104
|
+
await assert.rejects(
|
|
105
|
+
() => service.deleteRecord(9, {}),
|
|
106
|
+
(error) => error?.status === 404 && error?.message === "Record not found."
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("createCrudServiceFromResource validates required inputs", async () => {
|
|
111
|
+
assert.throws(
|
|
112
|
+
() => createCrudServiceFromResource({}),
|
|
113
|
+
/resource\.resource/
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const { createBaseService } = createCrudServiceFromResource({
|
|
117
|
+
resource: "contacts"
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.throws(
|
|
121
|
+
() => createBaseService({}),
|
|
122
|
+
/requires repository/
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const service = createBaseService({
|
|
126
|
+
repository: createRepositoryDouble({
|
|
127
|
+
async create() {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
});
|
|
132
|
+
await assert.rejects(
|
|
133
|
+
() => service.createRecord({}, {}),
|
|
134
|
+
/contactsService could not load the created record/
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("createCrudServiceFromResource readable field hooks require view output schema", async () => {
|
|
139
|
+
const { createBaseService } = createCrudServiceFromResource({
|
|
140
|
+
resource: "contacts"
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const service = createBaseService({
|
|
144
|
+
repository: createRepositoryDouble(),
|
|
145
|
+
fieldAccess: {
|
|
146
|
+
readable: () => ["id"]
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await assert.rejects(
|
|
151
|
+
() => service.getRecord(1, {}),
|
|
152
|
+
/requires resource\.operations\.view\.outputValidator\.schema for fieldAccess\.readable/
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("createCrudServiceFromResource enforces writable field access hooks", async () => {
|
|
157
|
+
const createCalls = [];
|
|
158
|
+
const { createBaseService } = createCrudServiceFromResource(createResourceWithOutputSchema());
|
|
159
|
+
|
|
160
|
+
const service = createBaseService({
|
|
161
|
+
repository: createRepositoryDouble({
|
|
162
|
+
async create(payload) {
|
|
163
|
+
createCalls.push(payload);
|
|
164
|
+
return { id: 2, ...payload };
|
|
165
|
+
}
|
|
166
|
+
}),
|
|
167
|
+
fieldAccess: {
|
|
168
|
+
writable: () => ["name"]
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await assert.rejects(
|
|
173
|
+
() => service.createRecord({ name: "A", optionalSecret: "hidden" }, {}),
|
|
174
|
+
(error) => error?.status === 403 && /Write access denied for fields: optionalSecret/.test(error?.message || "")
|
|
175
|
+
);
|
|
176
|
+
assert.equal(createCalls.length, 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("createCrudServiceFromResource supports writable field strip mode", async () => {
|
|
180
|
+
const createCalls = [];
|
|
181
|
+
const { createBaseService } = createCrudServiceFromResource(createResourceWithOutputSchema());
|
|
182
|
+
|
|
183
|
+
const service = createBaseService({
|
|
184
|
+
repository: createRepositoryDouble({
|
|
185
|
+
async create(payload) {
|
|
186
|
+
createCalls.push(payload);
|
|
187
|
+
return { id: 2, ...payload, nullableSecret: "x", defaultedSecret: "y" };
|
|
188
|
+
}
|
|
189
|
+
}),
|
|
190
|
+
fieldAccess: {
|
|
191
|
+
writable: () => ["name"],
|
|
192
|
+
writeMode: "strip"
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await service.createRecord({ name: "A", optionalSecret: "hidden" }, {});
|
|
197
|
+
assert.deepEqual(createCalls, [{ name: "A" }]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("createCrudServiceFromResource applies readable field access hooks with drop/null/default redaction", async () => {
|
|
201
|
+
const { createBaseService } = createCrudServiceFromResource(createResourceWithOutputSchema());
|
|
202
|
+
|
|
203
|
+
const service = createBaseService({
|
|
204
|
+
repository: createRepositoryDouble({
|
|
205
|
+
async findById() {
|
|
206
|
+
return {
|
|
207
|
+
id: 1,
|
|
208
|
+
name: "A",
|
|
209
|
+
optionalSecret: "drop-me",
|
|
210
|
+
nullableSecret: "redact-to-null",
|
|
211
|
+
defaultedSecret: "redact-to-default"
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}),
|
|
215
|
+
fieldAccess: {
|
|
216
|
+
readable: () => ["id", "name"]
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const record = await service.getRecord(1, {});
|
|
221
|
+
assert.deepEqual(record, {
|
|
222
|
+
id: 1,
|
|
223
|
+
name: "A",
|
|
224
|
+
nullableSecret: null,
|
|
225
|
+
defaultedSecret: ""
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("createCrudServiceFromResource readable filtering fails fast for required non-nullable fields without defaults", async () => {
|
|
230
|
+
const { createBaseService } = createCrudServiceFromResource({
|
|
231
|
+
resource: "contacts",
|
|
232
|
+
operations: {
|
|
233
|
+
view: {
|
|
234
|
+
outputValidator: {
|
|
235
|
+
schema: {
|
|
236
|
+
type: "object",
|
|
237
|
+
properties: {
|
|
238
|
+
id: { type: "integer" },
|
|
239
|
+
strictSecret: { type: "string" }
|
|
240
|
+
},
|
|
241
|
+
required: ["id", "strictSecret"]
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const service = createBaseService({
|
|
249
|
+
repository: createRepositoryDouble({
|
|
250
|
+
async findById() {
|
|
251
|
+
return { id: 1, strictSecret: "value" };
|
|
252
|
+
}
|
|
253
|
+
}),
|
|
254
|
+
fieldAccess: {
|
|
255
|
+
readable: () => ["id"]
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await assert.rejects(
|
|
260
|
+
() => service.getRecord(1, {}),
|
|
261
|
+
/cannot redact required non-nullable field "strictSecret" without schema\.default/
|
|
262
|
+
);
|
|
263
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
checkCrudLookupFormControl,
|
|
5
|
+
isCrudRuntimeOutputOnlyFieldKey
|
|
6
|
+
} from "../src/shared/crudFieldMetaSupport.js";
|
|
7
|
+
|
|
8
|
+
test("checkCrudLookupFormControl defaults to autocomplete", () => {
|
|
9
|
+
assert.equal(checkCrudLookupFormControl(undefined), "autocomplete");
|
|
10
|
+
assert.equal(checkCrudLookupFormControl(""), "autocomplete");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("checkCrudLookupFormControl accepts select", () => {
|
|
14
|
+
assert.equal(checkCrudLookupFormControl("select"), "select");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("checkCrudLookupFormControl accepts explicit empty default", () => {
|
|
18
|
+
assert.equal(checkCrudLookupFormControl(undefined, { defaultValue: "" }), "");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("checkCrudLookupFormControl throws for invalid value", () => {
|
|
22
|
+
assert.throws(
|
|
23
|
+
() => checkCrudLookupFormControl("Select", { context: "testContext" }),
|
|
24
|
+
/testContext must be "autocomplete" or "select"\./
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("isCrudRuntimeOutputOnlyFieldKey matches lookups", () => {
|
|
29
|
+
assert.equal(isCrudRuntimeOutputOnlyFieldKey("lookups"), true);
|
|
30
|
+
assert.equal(isCrudRuntimeOutputOnlyFieldKey(" lookups "), true);
|
|
31
|
+
assert.equal(isCrudRuntimeOutputOnlyFieldKey("id"), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("isCrudRuntimeOutputOnlyFieldKey supports custom lookup container key", () => {
|
|
35
|
+
assert.equal(
|
|
36
|
+
isCrudRuntimeOutputOnlyFieldKey("lookupData", {
|
|
37
|
+
lookupContainerKey: "lookupData"
|
|
38
|
+
}),
|
|
39
|
+
true
|
|
40
|
+
);
|
|
41
|
+
assert.equal(
|
|
42
|
+
isCrudRuntimeOutputOnlyFieldKey("lookups", {
|
|
43
|
+
lookupContainerKey: "lookupData"
|
|
44
|
+
}),
|
|
45
|
+
false
|
|
46
|
+
);
|
|
47
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createFieldAccessForRoleMatrix } from "../src/server/fieldAccess.js";
|
|
4
|
+
|
|
5
|
+
test("createFieldAccessForRoleMatrix resolves role and action policies", () => {
|
|
6
|
+
const fieldAccess = createFieldAccessForRoleMatrix({
|
|
7
|
+
default: {
|
|
8
|
+
readable: {
|
|
9
|
+
list: ["id", "name"],
|
|
10
|
+
"*": ["id"]
|
|
11
|
+
},
|
|
12
|
+
writable: {
|
|
13
|
+
create: ["name"],
|
|
14
|
+
update: ["name"]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
admin: {
|
|
18
|
+
readable: "*",
|
|
19
|
+
writable: "*"
|
|
20
|
+
},
|
|
21
|
+
writeMode: "strip"
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.equal(fieldAccess.writeMode, "strip");
|
|
25
|
+
assert.deepEqual(
|
|
26
|
+
fieldAccess.readable({
|
|
27
|
+
action: "list",
|
|
28
|
+
context: { auth: { role: "user" } }
|
|
29
|
+
}),
|
|
30
|
+
["id", "name"]
|
|
31
|
+
);
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
fieldAccess.readable({
|
|
34
|
+
action: "view",
|
|
35
|
+
context: { auth: { role: "user" } }
|
|
36
|
+
}),
|
|
37
|
+
["id"]
|
|
38
|
+
);
|
|
39
|
+
assert.equal(
|
|
40
|
+
fieldAccess.writable({
|
|
41
|
+
action: "update",
|
|
42
|
+
context: { auth: { role: "admin" } }
|
|
43
|
+
}),
|
|
44
|
+
"*"
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("createFieldAccessForRoleMatrix supports function policies and default role fallback", () => {
|
|
49
|
+
const fieldAccess = createFieldAccessForRoleMatrix({
|
|
50
|
+
default: {
|
|
51
|
+
readable: ({ action }) => (action === "list" ? ["id"] : ["id", "name"]),
|
|
52
|
+
writable: {
|
|
53
|
+
"*": ["name"]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
fieldAccess.readable({
|
|
60
|
+
action: "list",
|
|
61
|
+
context: { auth: { role: "unknown" } }
|
|
62
|
+
}),
|
|
63
|
+
["id"]
|
|
64
|
+
);
|
|
65
|
+
assert.deepEqual(
|
|
66
|
+
fieldAccess.readable({
|
|
67
|
+
action: "view",
|
|
68
|
+
context: { auth: { role: "unknown" } }
|
|
69
|
+
}),
|
|
70
|
+
["id", "name"]
|
|
71
|
+
);
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
fieldAccess.writable({
|
|
74
|
+
action: "update",
|
|
75
|
+
context: { auth: { role: "unknown" } }
|
|
76
|
+
}),
|
|
77
|
+
["name"]
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("createFieldAccessForRoleMatrix validates writeMode", () => {
|
|
82
|
+
assert.throws(
|
|
83
|
+
() => createFieldAccessForRoleMatrix({ writeMode: "invalid-mode" }),
|
|
84
|
+
/fieldAccess\.writeMode must be "throw" or "strip"/
|
|
85
|
+
);
|
|
86
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
cursorPaginationQueryValidator
|
|
5
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
6
|
+
import { compileRouteValidator } from "@jskit-ai/kernel/_testable";
|
|
7
|
+
import {
|
|
8
|
+
listSearchQueryValidator,
|
|
9
|
+
lookupIncludeQueryValidator,
|
|
10
|
+
createCrudParentFilterQueryValidator,
|
|
11
|
+
resolveCrudParentFilterKeys
|
|
12
|
+
} from "../src/server/listQueryValidators.js";
|
|
13
|
+
|
|
14
|
+
test("listSearchQueryValidator normalizes q", () => {
|
|
15
|
+
const normalized = listSearchQueryValidator.normalize({
|
|
16
|
+
q: " ani "
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.deepEqual(normalized, {
|
|
20
|
+
q: "ani"
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("listSearchQueryValidator keeps q optional when merged with pagination query validator", () => {
|
|
25
|
+
const compiled = compileRouteValidator({
|
|
26
|
+
queryValidator: [cursorPaginationQueryValidator, listSearchQueryValidator]
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
assert.deepEqual(compiled.schema.querystring.required || [], []);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("lookupIncludeQueryValidator normalizes include", () => {
|
|
33
|
+
const normalized = lookupIncludeQueryValidator.normalize({
|
|
34
|
+
include: " vetId,ownerId "
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(normalized, {
|
|
38
|
+
include: "vetId,ownerId"
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("lookupIncludeQueryValidator keeps include optional when merged with pagination and search", () => {
|
|
43
|
+
const compiled = compileRouteValidator({
|
|
44
|
+
queryValidator: [cursorPaginationQueryValidator, listSearchQueryValidator, lookupIncludeQueryValidator]
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.deepEqual(compiled.schema.querystring.required || [], []);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("resolveCrudParentFilterKeys returns lookup keys that exist in create schema", () => {
|
|
51
|
+
const resource = {
|
|
52
|
+
operations: {
|
|
53
|
+
create: {
|
|
54
|
+
bodyValidator: {
|
|
55
|
+
schema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
contactId: { type: "integer" },
|
|
59
|
+
name: { type: "string" },
|
|
60
|
+
vetId: { type: "integer" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
fieldMeta: [
|
|
67
|
+
{
|
|
68
|
+
key: "contactId",
|
|
69
|
+
relation: {
|
|
70
|
+
kind: "lookup",
|
|
71
|
+
apiPath: "/contacts",
|
|
72
|
+
valueKey: "id"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: "vetId",
|
|
77
|
+
relation: {
|
|
78
|
+
kind: "lookup",
|
|
79
|
+
apiPath: "/vets",
|
|
80
|
+
valueKey: "id"
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "ignoredLookup",
|
|
85
|
+
relation: {
|
|
86
|
+
kind: "lookup",
|
|
87
|
+
apiPath: "/ignored",
|
|
88
|
+
valueKey: "id"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
assert.deepEqual(resolveCrudParentFilterKeys(resource), ["contactId", "vetId"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("createCrudParentFilterQueryValidator normalizes configured parent filters", () => {
|
|
98
|
+
const validator = createCrudParentFilterQueryValidator({
|
|
99
|
+
operations: {
|
|
100
|
+
create: {
|
|
101
|
+
bodyValidator: {
|
|
102
|
+
schema: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
contactId: { type: "integer" }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
fieldMeta: [
|
|
112
|
+
{
|
|
113
|
+
key: "contactId",
|
|
114
|
+
relation: {
|
|
115
|
+
kind: "lookup",
|
|
116
|
+
apiPath: "/contacts",
|
|
117
|
+
valueKey: "id"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const normalized = validator.normalize({
|
|
124
|
+
contactId: " 42 ",
|
|
125
|
+
unknown: "x"
|
|
126
|
+
});
|
|
127
|
+
assert.deepEqual(normalized, {
|
|
128
|
+
contactId: "42"
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("createCrudParentFilterQueryValidator keeps parent filters optional when merged", () => {
|
|
133
|
+
const parentValidator = createCrudParentFilterQueryValidator({
|
|
134
|
+
operations: {
|
|
135
|
+
create: {
|
|
136
|
+
bodyValidator: {
|
|
137
|
+
schema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
contactId: { type: "integer" }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
fieldMeta: [
|
|
147
|
+
{
|
|
148
|
+
key: "contactId",
|
|
149
|
+
relation: {
|
|
150
|
+
kind: "lookup",
|
|
151
|
+
apiPath: "/contacts",
|
|
152
|
+
valueKey: "id"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const compiled = compileRouteValidator({
|
|
159
|
+
queryValidator: [cursorPaginationQueryValidator, listSearchQueryValidator, parentValidator]
|
|
160
|
+
});
|
|
161
|
+
assert.deepEqual(compiled.schema.querystring.required || [], []);
|
|
162
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
resolveCrudLookupProviderToken,
|
|
5
|
+
createCrudLookupProviderResolver,
|
|
6
|
+
createCrudLookupProvider
|
|
7
|
+
} from "../src/server/lookupProviders.js";
|
|
8
|
+
|
|
9
|
+
test("resolveCrudLookupProviderToken normalizes namespace to lookup token", () => {
|
|
10
|
+
assert.equal(resolveCrudLookupProviderToken("vets"), "crud.lookup.vets");
|
|
11
|
+
assert.equal(resolveCrudLookupProviderToken("vets/clinics/"), "crud.lookup.vets.clinics");
|
|
12
|
+
assert.equal(resolveCrudLookupProviderToken("contact-categories"), "crud.lookup.contact_categories");
|
|
13
|
+
assert.equal(resolveCrudLookupProviderToken("customer-categories/line-items"), "crud.lookup.customer_categories.line_items");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("resolveCrudLookupProviderToken throws for empty namespace", () => {
|
|
17
|
+
assert.throws(
|
|
18
|
+
() => resolveCrudLookupProviderToken(""),
|
|
19
|
+
/requires relation\.namespace/
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("createCrudLookupProviderResolver resolves providers through scope.make()", () => {
|
|
24
|
+
const calls = [];
|
|
25
|
+
const scope = {
|
|
26
|
+
make(token) {
|
|
27
|
+
calls.push(token);
|
|
28
|
+
return { token };
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const resolveLookupProvider = createCrudLookupProviderResolver(scope, {
|
|
33
|
+
context: "customersProvider"
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const resolved = resolveLookupProvider({
|
|
37
|
+
namespace: "vets"
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.equal(calls[0], "crud.lookup.vets");
|
|
41
|
+
assert.deepEqual(resolved, {
|
|
42
|
+
token: "crud.lookup.vets"
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("createCrudLookupProvider wraps repository.listByIds and preserves include when provided", async () => {
|
|
47
|
+
const calls = [];
|
|
48
|
+
const provider = createCrudLookupProvider({
|
|
49
|
+
async listByIds(ids = [], options = {}) {
|
|
50
|
+
calls.push({
|
|
51
|
+
ids,
|
|
52
|
+
options
|
|
53
|
+
});
|
|
54
|
+
return [{ id: 1 }];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await provider.listByIds([1, 2], {
|
|
59
|
+
include: "*",
|
|
60
|
+
limit: 10
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.deepEqual(result, [{ id: 1 }]);
|
|
64
|
+
assert.deepEqual(calls[0], {
|
|
65
|
+
ids: [1, 2],
|
|
66
|
+
options: {
|
|
67
|
+
include: "*",
|
|
68
|
+
limit: 10
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("createCrudLookupProvider defaults include=none when include is not provided", async () => {
|
|
74
|
+
const calls = [];
|
|
75
|
+
const provider = createCrudLookupProvider({
|
|
76
|
+
async listByIds(ids = [], options = {}) {
|
|
77
|
+
calls.push({
|
|
78
|
+
ids,
|
|
79
|
+
options
|
|
80
|
+
});
|
|
81
|
+
return [{ id: 1 }];
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await provider.listByIds([1, 2], {
|
|
86
|
+
limit: 10
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(calls[0], {
|
|
90
|
+
ids: [1, 2],
|
|
91
|
+
options: {
|
|
92
|
+
include: "none",
|
|
93
|
+
limit: 10
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("createCrudLookupProvider validates repository contract", () => {
|
|
99
|
+
assert.throws(
|
|
100
|
+
() => createCrudLookupProvider({}),
|
|
101
|
+
/requires repository\.listByIds/
|
|
102
|
+
);
|
|
103
|
+
});
|