@opensaas/stack-core 0.29.0 → 0.31.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +443 -0
- package/CLAUDE.md +4 -4
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +14 -1
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +50 -0
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/field-access.js +6 -6
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +46 -12
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.d.ts +60 -0
- package/dist/access/relationship-count.d.ts.map +1 -0
- package/dist/access/relationship-count.js +233 -0
- package/dist/access/relationship-count.js.map +1 -0
- package/dist/access/relationship-count.test.d.ts +2 -0
- package/dist/access/relationship-count.test.d.ts.map +1 -0
- package/dist/access/relationship-count.test.js +185 -0
- package/dist/access/relationship-count.test.js.map +1 -0
- package/dist/access/relationship-label-filter.d.ts +45 -0
- package/dist/access/relationship-label-filter.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.js +93 -0
- package/dist/access/relationship-label-filter.js.map +1 -0
- package/dist/access/relationship-label-filter.test.d.ts +2 -0
- package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.test.js +126 -0
- package/dist/access/relationship-label-filter.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/nav-count.d.ts +34 -0
- package/dist/config/nav-count.d.ts.map +1 -0
- package/dist/config/nav-count.js +71 -0
- package/dist/config/nav-count.js.map +1 -0
- package/dist/config/types.d.ts +305 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +11 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +28 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +206 -1
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +7 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/extend.d.ts +1 -0
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +166 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +32 -0
- package/dist/filter/collect.d.ts.map +1 -0
- package/dist/filter/collect.js +60 -0
- package/dist/filter/collect.js.map +1 -0
- package/dist/filter/filter.test.d.ts +2 -0
- package/dist/filter/filter.test.d.ts.map +1 -0
- package/dist/filter/filter.test.js +348 -0
- package/dist/filter/filter.test.js.map +1 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +16 -0
- package/dist/filter/index.js.map +1 -0
- package/dist/filter/map.d.ts +18 -0
- package/dist/filter/map.d.ts.map +1 -0
- package/dist/filter/map.js +60 -0
- package/dist/filter/map.js.map +1 -0
- package/dist/filter/parse.d.ts +33 -0
- package/dist/filter/parse.d.ts.map +1 -0
- package/dist/filter/parse.js +103 -0
- package/dist/filter/parse.js.map +1 -0
- package/dist/filter/serialize.d.ts +25 -0
- package/dist/filter/serialize.d.ts.map +1 -0
- package/dist/filter/serialize.js +68 -0
- package/dist/filter/serialize.js.map +1 -0
- package/dist/filter/serialize.test.d.ts +2 -0
- package/dist/filter/serialize.test.d.ts.map +1 -0
- package/dist/filter/serialize.test.js +83 -0
- package/dist/filter/serialize.test.js.map +1 -0
- package/dist/filter/types.d.ts +122 -0
- package/dist/filter/types.d.ts.map +1 -0
- package/dist/filter/types.js +24 -0
- package/dist/filter/types.js.map +1 -0
- package/dist/hooks/index.d.ts +30 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +79 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/mcp/handler.d.ts +11 -3
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +96 -20
- package/dist/mcp/handler.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +79 -0
- package/src/access/access-filter.ts +15 -4
- package/src/access/field-access.ts +6 -6
- package/src/access/multi-column-read-write.test.ts +68 -19
- package/src/access/relationship-count.test.ts +269 -0
- package/src/access/relationship-count.ts +311 -0
- package/src/access/relationship-label-filter.test.ts +177 -0
- package/src/access/relationship-label-filter.ts +140 -0
- package/src/config/index.ts +6 -0
- package/src/config/nav-count.ts +85 -0
- package/src/config/types.ts +319 -1
- package/src/context/hook-pipeline.ts +19 -0
- package/src/context/index.ts +286 -4
- package/src/context/nested-operations.ts +22 -0
- package/src/extend.ts +9 -0
- package/src/fields/index.ts +168 -0
- package/src/filter/collect.ts +72 -0
- package/src/filter/filter.test.ts +400 -0
- package/src/filter/index.ts +25 -0
- package/src/filter/map.ts +68 -0
- package/src/filter/parse.ts +106 -0
- package/src/filter/serialize.test.ts +115 -0
- package/src/filter/serialize.ts +75 -0
- package/src/filter/types.ts +120 -0
- package/src/hooks/index.ts +87 -57
- package/src/index.ts +54 -0
- package/src/internal.ts +5 -0
- package/src/mcp/handler.ts +118 -28
- package/tests/context.test.ts +690 -0
- package/tests/multi-column-validation-ordering.test.ts +305 -0
- package/tests/nav-count.test.ts +189 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { getContext } from '../src/context/index.js'
|
|
4
|
+
import { config, list } from '../src/config/index.js'
|
|
5
|
+
import { text, relationship } from '../src/fields/index.js'
|
|
6
|
+
import { ValidationError } from '../src/hooks/index.js'
|
|
7
|
+
import type { FieldConfig } from '../src/config/types.js'
|
|
8
|
+
import type { FieldAccess } from '../src/access/types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* #789: a multi-column field's `resolveInput` can have a documented
|
|
12
|
+
* "unrecognised value — return as-is and let validation catch it" fallback
|
|
13
|
+
* (see storage image()/file() in Keystone-parity mode). Before this fix, the
|
|
14
|
+
* split into physical columns ran BEFORE validation (inline inside
|
|
15
|
+
* `executeFieldResolveInputHooks`), so an unrecognised value was silently split
|
|
16
|
+
* into null/undefined columns and the write succeeded with corrupted/absent
|
|
17
|
+
* data instead of throwing.
|
|
18
|
+
*
|
|
19
|
+
* These are full `context.db` integration tests (top-level AND nested writes)
|
|
20
|
+
* proving validation now runs against the LOGICAL value first — an
|
|
21
|
+
* unrecognised value throws `ValidationError` and the DB is never touched —
|
|
22
|
+
* while the legitimate shapes (null/undefined and a valid object) continue to
|
|
23
|
+
* split and persist exactly as before.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A minimal multi-column field: two physical columns split from/assembled into `{ url, size }`. */
|
|
27
|
+
function mediaField(access?: FieldAccess): FieldConfig {
|
|
28
|
+
const COLUMNS = ['m_url', 'm_size']
|
|
29
|
+
return {
|
|
30
|
+
type: 'multiColumn',
|
|
31
|
+
access,
|
|
32
|
+
getColumnNames: () => COLUMNS,
|
|
33
|
+
assembleColumns: (_fieldName: string, row: Record<string, unknown>) => {
|
|
34
|
+
const url = row.m_url
|
|
35
|
+
if (url === null || url === undefined || url === '') return null
|
|
36
|
+
return { url, size: row.m_size ?? 0 }
|
|
37
|
+
},
|
|
38
|
+
splitColumns: (_fieldName: string, value: unknown) => {
|
|
39
|
+
if (value === null || value === undefined) {
|
|
40
|
+
return { m_url: null, m_size: null }
|
|
41
|
+
}
|
|
42
|
+
const v = value as { url?: unknown; size?: unknown }
|
|
43
|
+
return { m_url: v.url ?? null, m_size: v.size ?? null }
|
|
44
|
+
},
|
|
45
|
+
getZodSchema: () => z.object({ url: z.string(), size: z.number() }).nullable(),
|
|
46
|
+
hooks: {
|
|
47
|
+
// Mirrors storage image()/file()'s resolveInput contract: null/undefined
|
|
48
|
+
// and an already-shaped value pass through unchanged; anything else is
|
|
49
|
+
// the documented "unrecognised — return as-is and let validation catch
|
|
50
|
+
// it" fallback. This is the exact fallback #789 is about.
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic test hook
|
|
52
|
+
resolveInput: async ({ resolvedData, fieldKey }: any) => resolvedData?.[fieldKey],
|
|
53
|
+
},
|
|
54
|
+
} as unknown as FieldConfig
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A tiny in-memory Prisma mock supporting a single nested to-one relation
|
|
59
|
+
* (`author` on `post`), for both nested create and nested update, mirroring
|
|
60
|
+
* the harness used by the other write-pipeline test suites.
|
|
61
|
+
*/
|
|
62
|
+
function createTxPrisma() {
|
|
63
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {
|
|
64
|
+
post: new Map(),
|
|
65
|
+
author: new Map(),
|
|
66
|
+
}
|
|
67
|
+
let idCounter = 0
|
|
68
|
+
const nextId = () => `id-${++idCounter}`
|
|
69
|
+
|
|
70
|
+
function applyNested(
|
|
71
|
+
table: string,
|
|
72
|
+
record: Record<string, unknown>,
|
|
73
|
+
data: Record<string, unknown>,
|
|
74
|
+
): Record<string, unknown> {
|
|
75
|
+
const result = { ...record }
|
|
76
|
+
for (const [key, value] of Object.entries(data)) {
|
|
77
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
78
|
+
const nested = value as Record<string, unknown>
|
|
79
|
+
if (nested.create || nested.update) {
|
|
80
|
+
if (nested.create) {
|
|
81
|
+
const created = doCreate('author', nested.create as Record<string, unknown>)
|
|
82
|
+
result[`${key}Link`] = created.id
|
|
83
|
+
result[key] = created
|
|
84
|
+
}
|
|
85
|
+
if (nested.update) {
|
|
86
|
+
const upd = nested.update as { where: { id: string }; data: Record<string, unknown> }
|
|
87
|
+
const updated = doUpdate('author', upd.where, upd.data)
|
|
88
|
+
result[key] = updated
|
|
89
|
+
}
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
result[key] = value
|
|
94
|
+
}
|
|
95
|
+
return result
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function doCreate(table: string, data: Record<string, unknown>): Record<string, unknown> {
|
|
99
|
+
const id = (data.id as string) ?? nextId()
|
|
100
|
+
const record = applyNested(table, { id }, data)
|
|
101
|
+
tables[table].set(id, record)
|
|
102
|
+
return record
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function doUpdate(
|
|
106
|
+
table: string,
|
|
107
|
+
where: { id: string },
|
|
108
|
+
data: Record<string, unknown>,
|
|
109
|
+
): Record<string, unknown> {
|
|
110
|
+
const existing = tables[table].get(where.id) ?? { id: where.id }
|
|
111
|
+
const updated = applyNested(table, existing, data)
|
|
112
|
+
tables[table].set(where.id, updated)
|
|
113
|
+
return updated
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function makeModel(table: string) {
|
|
117
|
+
return {
|
|
118
|
+
findUnique: vi.fn(
|
|
119
|
+
async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
|
|
120
|
+
),
|
|
121
|
+
findFirst: vi.fn(async () => tables[table].values().next().value ?? null),
|
|
122
|
+
findMany: vi.fn(async () => Array.from(tables[table].values())),
|
|
123
|
+
count: vi.fn(async () => tables[table].size),
|
|
124
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => doCreate(table, data)),
|
|
125
|
+
update: vi.fn(
|
|
126
|
+
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) =>
|
|
127
|
+
doUpdate(table, where, data),
|
|
128
|
+
),
|
|
129
|
+
delete: vi.fn(),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const client: Record<string, unknown> = {
|
|
134
|
+
post: makeModel('post'),
|
|
135
|
+
author: makeModel('author'),
|
|
136
|
+
}
|
|
137
|
+
client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => fn(client)
|
|
138
|
+
|
|
139
|
+
return { client, tables }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('#789 top-level write — multi-column validation runs BEFORE the split', () => {
|
|
143
|
+
let mock: ReturnType<typeof createTxPrisma>
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
mock = createTxPrisma()
|
|
147
|
+
vi.clearAllMocks()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
function makeTestConfig() {
|
|
151
|
+
return config({
|
|
152
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
153
|
+
lists: {
|
|
154
|
+
Post: list({
|
|
155
|
+
fields: { title: text(), media: mediaField() },
|
|
156
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
it('create: an unrecognised media value throws ValidationError and never reaches the DB', async () => {
|
|
163
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
164
|
+
|
|
165
|
+
await expect(
|
|
166
|
+
context.db.post.create({ data: { title: 'hi', media: 'not-a-valid-shape' } }),
|
|
167
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
168
|
+
|
|
169
|
+
expect(mock.tables.post.size).toBe(0)
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
171
|
+
expect((mock.client.post as any).create).not.toHaveBeenCalled()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('create: a valid media value passes validation and is split into physical columns', async () => {
|
|
175
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
176
|
+
|
|
177
|
+
const created = await context.db.post.create({
|
|
178
|
+
data: { title: 'hi', media: { url: 'https://x/y.jpg', size: 99 } },
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
expect(created).toBeTruthy()
|
|
182
|
+
const stored = mock.tables.post.values().next().value
|
|
183
|
+
expect(stored).toMatchObject({ m_url: 'https://x/y.jpg', m_size: 99 })
|
|
184
|
+
expect('media' in (stored ?? {})).toBe(false)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('create: a null media value passes validation and clears the columns', async () => {
|
|
188
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
189
|
+
|
|
190
|
+
await context.db.post.create({ data: { title: 'hi', media: null } })
|
|
191
|
+
|
|
192
|
+
const stored = mock.tables.post.values().next().value
|
|
193
|
+
expect(stored).toMatchObject({ m_url: null, m_size: null })
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('update: an unrecognised media value throws ValidationError and never reaches the DB', async () => {
|
|
197
|
+
mock.tables.post.set('p1', { id: 'p1', title: 'old', m_url: null, m_size: null })
|
|
198
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
context.db.post.update({ where: { id: 'p1' }, data: { media: 42 } }),
|
|
202
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
203
|
+
|
|
204
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
205
|
+
expect((mock.client.post as any).update).not.toHaveBeenCalled()
|
|
206
|
+
expect(mock.tables.post.get('p1')).toMatchObject({ title: 'old', m_url: null, m_size: null })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('update: a valid media value passes validation and is split into physical columns', async () => {
|
|
210
|
+
mock.tables.post.set('p1', { id: 'p1', title: 'old', m_url: null, m_size: null })
|
|
211
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
212
|
+
|
|
213
|
+
await context.db.post.update({
|
|
214
|
+
where: { id: 'p1' },
|
|
215
|
+
data: { media: { url: 'https://x/new.jpg', size: 5 } },
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
expect(mock.tables.post.get('p1')).toMatchObject({ m_url: 'https://x/new.jpg', m_size: 5 })
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('#789 nested write — multi-column validation runs BEFORE the split', () => {
|
|
223
|
+
let mock: ReturnType<typeof createTxPrisma>
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
mock = createTxPrisma()
|
|
227
|
+
vi.clearAllMocks()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
function makeTestConfig() {
|
|
231
|
+
return config({
|
|
232
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
233
|
+
lists: {
|
|
234
|
+
Author: list({
|
|
235
|
+
fields: { name: text(), media: mediaField() },
|
|
236
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
237
|
+
}),
|
|
238
|
+
Post: list({
|
|
239
|
+
fields: { title: text(), author: relationship({ ref: 'Author' }) },
|
|
240
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it('nested create: an unrecognised media value throws ValidationError and nothing persists', async () => {
|
|
247
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
context.db.post.create({
|
|
251
|
+
data: { title: 'p', author: { create: { name: 'a', media: 'not-a-valid-shape' } } },
|
|
252
|
+
}),
|
|
253
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
254
|
+
|
|
255
|
+
expect(mock.tables.post.size).toBe(0)
|
|
256
|
+
expect(mock.tables.author.size).toBe(0)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('nested create: a valid media value is split into physical columns on the nested row', async () => {
|
|
260
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
261
|
+
|
|
262
|
+
await context.db.post.create({
|
|
263
|
+
data: {
|
|
264
|
+
title: 'p',
|
|
265
|
+
author: { create: { name: 'a', media: { url: 'https://x/y.jpg', size: 3 } } },
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
const author = mock.tables.author.values().next().value
|
|
270
|
+
expect(author).toMatchObject({ m_url: 'https://x/y.jpg', m_size: 3 })
|
|
271
|
+
expect('media' in (author ?? {})).toBe(false)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('nested update: an unrecognised media value throws ValidationError and nothing persists', async () => {
|
|
275
|
+
mock.tables.post.set('p1', { id: 'p1', title: 'p', authorLink: 'a1' })
|
|
276
|
+
mock.tables.author.set('a1', { id: 'a1', name: 'old', m_url: null, m_size: null })
|
|
277
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
278
|
+
|
|
279
|
+
await expect(
|
|
280
|
+
context.db.post.update({
|
|
281
|
+
where: { id: 'p1' },
|
|
282
|
+
data: { author: { update: { where: { id: 'a1' }, data: { media: [] } } } },
|
|
283
|
+
}),
|
|
284
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
285
|
+
|
|
286
|
+
expect(mock.tables.author.get('a1')).toMatchObject({ name: 'old', m_url: null, m_size: null })
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('nested update: a valid media value is split into physical columns on the nested row', async () => {
|
|
290
|
+
mock.tables.post.set('p1', { id: 'p1', title: 'p', authorLink: 'a1' })
|
|
291
|
+
mock.tables.author.set('a1', { id: 'a1', name: 'old', m_url: null, m_size: null })
|
|
292
|
+
const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
|
|
293
|
+
|
|
294
|
+
await context.db.post.update({
|
|
295
|
+
where: { id: 'p1' },
|
|
296
|
+
data: {
|
|
297
|
+
author: {
|
|
298
|
+
update: { where: { id: 'a1' }, data: { media: { url: 'https://x/new.jpg', size: 8 } } },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
expect(mock.tables.author.get('a1')).toMatchObject({ m_url: 'https://x/new.jpg', m_size: 8 })
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { isListQueryStaticallyDenied, resolveNavCounts } from '../src/config/nav-count.js'
|
|
3
|
+
import type { AccessContext } from '../src/access/types.js'
|
|
4
|
+
import type { ListConfig, OpenSaasConfig, TypeInfo } from '../src/config/types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a list config with a chosen query access and `ui.navCount` / singleton
|
|
8
|
+
* flags. Access is written in the normalized object form `list()` produces.
|
|
9
|
+
*/
|
|
10
|
+
function makeList(options: {
|
|
11
|
+
queryAccess?: boolean | (() => boolean) | undefined
|
|
12
|
+
navCount?: boolean
|
|
13
|
+
isSingleton?: boolean
|
|
14
|
+
}): ListConfig<TypeInfo> {
|
|
15
|
+
const { queryAccess, navCount, isSingleton } = options
|
|
16
|
+
return {
|
|
17
|
+
fields: { title: { type: 'text' } as ListConfig<TypeInfo>['fields'][string] },
|
|
18
|
+
access: queryAccess === undefined ? undefined : { operation: { query: queryAccess } },
|
|
19
|
+
ui: navCount !== undefined ? { navCount } : undefined,
|
|
20
|
+
isSingleton,
|
|
21
|
+
} as ListConfig<TypeInfo>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A context whose `db[key].count` is a spy resolving to a fixed number, so we
|
|
26
|
+
* can assert both the returned counts and WHICH lists were queried.
|
|
27
|
+
*/
|
|
28
|
+
function makeContext(counts: Record<string, number>): {
|
|
29
|
+
context: AccessContext
|
|
30
|
+
spies: Record<string, ReturnType<typeof vi.fn>>
|
|
31
|
+
} {
|
|
32
|
+
const spies: Record<string, ReturnType<typeof vi.fn>> = {}
|
|
33
|
+
const db: Record<string, { count: ReturnType<typeof vi.fn> }> = {}
|
|
34
|
+
for (const [dbKey, value] of Object.entries(counts)) {
|
|
35
|
+
const spy = vi.fn(async () => value)
|
|
36
|
+
spies[dbKey] = spy
|
|
37
|
+
db[dbKey] = { count: spy }
|
|
38
|
+
}
|
|
39
|
+
const context = {
|
|
40
|
+
db,
|
|
41
|
+
session: null,
|
|
42
|
+
prisma: {},
|
|
43
|
+
storage: {},
|
|
44
|
+
plugins: {},
|
|
45
|
+
_isSudo: false,
|
|
46
|
+
_resolveOutputCounter: { depth: 0 },
|
|
47
|
+
} as unknown as AccessContext
|
|
48
|
+
return { context, spies }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('isListQueryStaticallyDenied', () => {
|
|
52
|
+
it('is denied when query access is absent (deny-by-default)', () => {
|
|
53
|
+
expect(isListQueryStaticallyDenied(makeList({ queryAccess: undefined }))).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('is denied when query access is the literal false', () => {
|
|
57
|
+
expect(isListQueryStaticallyDenied(makeList({ queryAccess: false }))).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('is not denied when query access is the literal true', () => {
|
|
61
|
+
expect(isListQueryStaticallyDenied(makeList({ queryAccess: true }))).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('is not denied when query access is a function (decided per request)', () => {
|
|
65
|
+
expect(isListQueryStaticallyDenied(makeList({ queryAccess: () => false }))).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('resolveNavCounts', () => {
|
|
70
|
+
it('counts only lists that opt in via ui.navCount', async () => {
|
|
71
|
+
const config: OpenSaasConfig = {
|
|
72
|
+
db: { provider: 'sqlite', url: 'file:./t.db' },
|
|
73
|
+
lists: {
|
|
74
|
+
Post: makeList({ queryAccess: true, navCount: true }),
|
|
75
|
+
Comment: makeList({ queryAccess: true, navCount: false }),
|
|
76
|
+
Tag: makeList({ queryAccess: true }), // no ui.navCount at all
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
const { context, spies } = makeContext({ post: 3, comment: 9, tag: 2 })
|
|
80
|
+
|
|
81
|
+
const counts = await resolveNavCounts(context, config)
|
|
82
|
+
|
|
83
|
+
expect(counts).toEqual({ Post: 3 })
|
|
84
|
+
// No count query runs for lists that didn't opt in.
|
|
85
|
+
expect(spies.post).toHaveBeenCalledTimes(1)
|
|
86
|
+
expect(spies.comment).not.toHaveBeenCalled()
|
|
87
|
+
expect(spies.tag).not.toHaveBeenCalled()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('omits an opted-in list whose query access is statically denied (no misleading zero, no query)', async () => {
|
|
91
|
+
const config: OpenSaasConfig = {
|
|
92
|
+
db: { provider: 'sqlite', url: 'file:./t.db' },
|
|
93
|
+
lists: {
|
|
94
|
+
Secret: makeList({ queryAccess: false, navCount: true }),
|
|
95
|
+
Missing: makeList({ queryAccess: undefined, navCount: true }),
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
const { context, spies } = makeContext({ secret: 5, missing: 5 })
|
|
99
|
+
|
|
100
|
+
const counts = await resolveNavCounts(context, config)
|
|
101
|
+
|
|
102
|
+
expect(counts).toEqual({})
|
|
103
|
+
expect(spies.secret).not.toHaveBeenCalled()
|
|
104
|
+
expect(spies.missing).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('counts a list with function query access through the secured context', async () => {
|
|
108
|
+
const config: OpenSaasConfig = {
|
|
109
|
+
db: { provider: 'sqlite', url: 'file:./t.db' },
|
|
110
|
+
lists: {
|
|
111
|
+
Post: makeList({ queryAccess: () => true, navCount: true }),
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
const { context, spies } = makeContext({ post: 7 })
|
|
115
|
+
|
|
116
|
+
const counts = await resolveNavCounts(context, config)
|
|
117
|
+
|
|
118
|
+
expect(counts).toEqual({ Post: 7 })
|
|
119
|
+
expect(spies.post).toHaveBeenCalledTimes(1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('reports an access-scoped count of zero (session sees none) as 0, not omitted', async () => {
|
|
123
|
+
const config: OpenSaasConfig = {
|
|
124
|
+
db: { provider: 'sqlite', url: 'file:./t.db' },
|
|
125
|
+
lists: {
|
|
126
|
+
Post: makeList({ queryAccess: () => true, navCount: true }),
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
const { context } = makeContext({ post: 0 })
|
|
130
|
+
|
|
131
|
+
const counts = await resolveNavCounts(context, config)
|
|
132
|
+
|
|
133
|
+
expect(counts).toEqual({ Post: 0 })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('skips singletons even when opted in (a single-record list has no meaningful count)', async () => {
|
|
137
|
+
const config: OpenSaasConfig = {
|
|
138
|
+
db: { provider: 'sqlite', url: 'file:./t.db' },
|
|
139
|
+
lists: {
|
|
140
|
+
SiteSettings: makeList({ queryAccess: true, navCount: true, isSingleton: true }),
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
const { context, spies } = makeContext({ siteSettings: 1 })
|
|
144
|
+
|
|
145
|
+
const counts = await resolveNavCounts(context, config)
|
|
146
|
+
|
|
147
|
+
expect(counts).toEqual({})
|
|
148
|
+
expect(spies.siteSettings).not.toHaveBeenCalled()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("degrades gracefully when one list's count rejects — omits that badge, keeps the rest, never throws", async () => {
|
|
152
|
+
const config: OpenSaasConfig = {
|
|
153
|
+
db: { provider: 'sqlite', url: 'file:./t.db' },
|
|
154
|
+
lists: {
|
|
155
|
+
Post: makeList({ queryAccess: true, navCount: true }),
|
|
156
|
+
Comment: makeList({ queryAccess: true, navCount: true }),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
// Post's count throws (e.g. a DB hiccup or a throwing access filter);
|
|
160
|
+
// Comment's succeeds. The whole admin chrome must still render.
|
|
161
|
+
const context = {
|
|
162
|
+
db: {
|
|
163
|
+
post: { count: async () => Promise.reject(new Error('db exploded')) },
|
|
164
|
+
comment: { count: async () => 4 },
|
|
165
|
+
},
|
|
166
|
+
session: null,
|
|
167
|
+
prisma: {},
|
|
168
|
+
storage: {},
|
|
169
|
+
plugins: {},
|
|
170
|
+
_isSudo: false,
|
|
171
|
+
_resolveOutputCounter: { depth: 0 },
|
|
172
|
+
} as unknown as AccessContext
|
|
173
|
+
|
|
174
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
175
|
+
try {
|
|
176
|
+
const counts = await resolveNavCounts(context, config)
|
|
177
|
+
|
|
178
|
+
// Rejecting list is omitted; the healthy list's count is unaffected.
|
|
179
|
+
expect(counts).toEqual({ Comment: 4 })
|
|
180
|
+
// The failure is logged, not swallowed silently.
|
|
181
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
182
|
+
expect.stringContaining('Failed to resolve nav count for Post'),
|
|
183
|
+
expect.any(Error),
|
|
184
|
+
)
|
|
185
|
+
} finally {
|
|
186
|
+
errorSpy.mockRestore()
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
})
|