@igstack/app-catalog-backend-core 6.10.2 → 6.11.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/dist/db/syncAppCatalog.mjs +1 -1
- package/dist/generated/prisma/client.d.mts +1 -1
- package/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/commonInputTypes.d.mts +33 -3
- package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
- package/dist/generated/prisma/enums.d.mts +6 -1
- package/dist/generated/prisma/enums.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +84 -0
- package/dist/generated/prisma/internal/class.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.mjs +4 -4
- package/dist/generated/prisma/internal/class.mjs.map +1 -1
- package/dist/generated/prisma/internal/prismaNamespace.d.mts +617 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbCommunityTool.d.mts +2852 -0
- package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolLike.d.mts +1102 -0
- package/dist/generated/prisma/models/DbCommunityToolLike.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts +1034 -0
- package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts +1034 -0
- package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolPin.d.mts +1110 -0
- package/dist/generated/prisma/models/DbCommunityToolPin.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolTag.d.mts +1073 -0
- package/dist/generated/prisma/models/DbCommunityToolTag.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolView.d.mts +1149 -0
- package/dist/generated/prisma/models/DbCommunityToolView.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbFeedback.d.mts +361 -2
- package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -1
- package/dist/generated/prisma/models.d.mts +9 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -1
- package/dist/middleware/createAcMiddleware.d.mts.map +1 -1
- package/dist/middleware/createAcMiddleware.mjs +15 -1
- package/dist/middleware/createAcMiddleware.mjs.map +1 -1
- package/dist/middleware/database.d.mts +1 -1
- package/dist/middleware/types.d.mts +20 -1
- package/dist/middleware/types.d.mts.map +1 -1
- package/dist/modules/communityTools/capture.d.mts +27 -0
- package/dist/modules/communityTools/capture.d.mts.map +1 -0
- package/dist/modules/communityTools/capture.mjs +192 -0
- package/dist/modules/communityTools/capture.mjs.map +1 -0
- package/dist/modules/communityTools/healthJob.mjs +132 -0
- package/dist/modules/communityTools/healthJob.mjs.map +1 -0
- package/dist/modules/communityTools/router.d.mts +150 -0
- package/dist/modules/communityTools/router.d.mts.map +1 -0
- package/dist/modules/communityTools/router.mjs +91 -0
- package/dist/modules/communityTools/router.mjs.map +1 -0
- package/dist/modules/communityTools/service.mjs +252 -0
- package/dist/modules/communityTools/service.mjs.map +1 -0
- package/dist/modules/communityTools/toView.mjs +81 -0
- package/dist/modules/communityTools/toView.mjs.map +1 -0
- package/dist/modules/communityTools/writeService.d.mts +55 -0
- package/dist/modules/communityTools/writeService.d.mts.map +1 -0
- package/dist/modules/communityTools/writeService.mjs +280 -0
- package/dist/modules/communityTools/writeService.mjs.map +1 -0
- package/dist/modules/feedback/feedbackRouter.d.mts +3 -0
- package/dist/modules/feedback/feedbackRouter.mjs +6 -2
- package/dist/modules/feedback/feedbackRouter.mjs.map +1 -1
- package/dist/modules/feedback/service.d.mts +3 -0
- package/dist/modules/feedback/service.d.mts.map +1 -1
- package/dist/modules/feedback/service.mjs +50 -17
- package/dist/modules/feedback/service.mjs.map +1 -1
- package/dist/server/controller.d.mts +143 -0
- package/dist/server/controller.d.mts.map +1 -1
- package/dist/server/controller.mjs +2 -0
- package/dist/server/controller.mjs.map +1 -1
- package/dist/server/trpcSetup.mjs +1 -1
- package/package.json +12 -5
- package/prisma/schema.prisma +161 -0
- package/src/__tests__/communityTools.test.ts +781 -0
- package/src/__tests__/communityToolsCapture.test.ts +319 -0
- package/src/__tests__/communityToolsHealth.test.ts +154 -0
- package/src/__tests__/communityToolsRouter.test.ts +58 -0
- package/src/__tests__/communityToolsWrite.test.ts +561 -0
- package/src/__tests__/feedbackService.test.ts +160 -0
- package/src/generated/prisma/browser.ts +35 -0
- package/src/generated/prisma/client.ts +35 -0
- package/src/generated/prisma/commonInputTypes.ts +34 -0
- package/src/generated/prisma/enums.ts +8 -0
- package/src/generated/prisma/internal/class.ts +74 -4
- package/src/generated/prisma/internal/prismaNamespace.ts +635 -2
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +95 -1
- package/src/generated/prisma/models/DbCommunityTool.ts +3135 -0
- package/src/generated/prisma/models/DbCommunityToolLike.ts +1313 -0
- package/src/generated/prisma/models/DbCommunityToolNamedOwner.ts +1243 -0
- package/src/generated/prisma/models/DbCommunityToolOwner.ts +1243 -0
- package/src/generated/prisma/models/DbCommunityToolPin.ts +1325 -0
- package/src/generated/prisma/models/DbCommunityToolTag.ts +1283 -0
- package/src/generated/prisma/models/DbCommunityToolView.ts +1361 -0
- package/src/generated/prisma/models/DbFeedback.ts +379 -1
- package/src/generated/prisma/models.ts +7 -0
- package/src/index.ts +16 -0
- package/src/middleware/createAcMiddleware.ts +20 -0
- package/src/middleware/types.ts +21 -0
- package/src/modules/communityTools/capture.ts +332 -0
- package/src/modules/communityTools/healthJob.ts +191 -0
- package/src/modules/communityTools/router.ts +172 -0
- package/src/modules/communityTools/service.ts +380 -0
- package/src/modules/communityTools/toView.ts +112 -0
- package/src/modules/communityTools/writeService.ts +408 -0
- package/src/modules/feedback/feedbackRouter.ts +20 -6
- package/src/modules/feedback/service.ts +68 -12
- package/src/server/controller.ts +8 -1
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import sharp from 'sharp'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import type { User } from 'better-auth/types'
|
|
6
|
+
import * as dbClient from '../db/client'
|
|
7
|
+
import type { PrismaClient } from '../generated/prisma/client'
|
|
8
|
+
import type { ScreenshotCapturer } from '../modules/communityTools/capture'
|
|
9
|
+
import * as write from '../modules/communityTools/writeService'
|
|
10
|
+
|
|
11
|
+
const NOW = new Date('2026-10-21T12:00:00.000Z')
|
|
12
|
+
|
|
13
|
+
function mockUser(id: string, email: string, name: string): User {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
email,
|
|
17
|
+
name,
|
|
18
|
+
createdAt: NOW,
|
|
19
|
+
updatedAt: NOW,
|
|
20
|
+
emailVerified: false,
|
|
21
|
+
image: null,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ann = mockUser('user-1', 'ann@example.com', 'Ann Owner')
|
|
26
|
+
const bob = mockUser('user-2', 'bob@example.com', 'Bob Stranger')
|
|
27
|
+
const actorFor = (id: string) => ({
|
|
28
|
+
hash: `hash-${id}`,
|
|
29
|
+
publicId: `pub-${id}`,
|
|
30
|
+
alias: 'Calm Otter',
|
|
31
|
+
kind: 'user' as const,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const dbTool = {
|
|
35
|
+
id: 'tool-1',
|
|
36
|
+
slug: 'my-tool',
|
|
37
|
+
name: 'My Tool',
|
|
38
|
+
url: 'https://my-tool.pages.example.com/',
|
|
39
|
+
host: 'my-tool.pages.example.com',
|
|
40
|
+
summary: 'Does a thing',
|
|
41
|
+
descriptionMd: 'Longer words',
|
|
42
|
+
addedByUserId: 'user-1',
|
|
43
|
+
addedByName: 'Ann Owner',
|
|
44
|
+
createdAt: NOW,
|
|
45
|
+
updatedAt: NOW,
|
|
46
|
+
reviewedAt: null,
|
|
47
|
+
reviewedByName: null,
|
|
48
|
+
lastCheckAt: null,
|
|
49
|
+
failingSince: null,
|
|
50
|
+
archivedAt: null,
|
|
51
|
+
archivedReason: null,
|
|
52
|
+
archivedBy: null,
|
|
53
|
+
screenshotAssetId: null,
|
|
54
|
+
faviconAssetId: null,
|
|
55
|
+
capturedAt: null,
|
|
56
|
+
siteUpdatedAt: null,
|
|
57
|
+
owners: [{ toolId: 'tool-1', personSlug: 'ann@example.com' }],
|
|
58
|
+
namedOwners: [] as { toolId: string; name: string }[],
|
|
59
|
+
likes: [] as { userId: string; userName: string }[],
|
|
60
|
+
tags: [],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const input = {
|
|
64
|
+
url: 'https://my-tool.pages.example.com/',
|
|
65
|
+
name: 'My Tool',
|
|
66
|
+
summary: 'Does a thing',
|
|
67
|
+
descriptionMd: 'Longer words',
|
|
68
|
+
ownerSlugs: ['ann@example.com'],
|
|
69
|
+
ownerNames: [] as string[],
|
|
70
|
+
tags: [],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const noCapture = { captureHosts: [], capturer: null }
|
|
74
|
+
|
|
75
|
+
describe('Community tools write service', () => {
|
|
76
|
+
let db: ReturnType<typeof makeDb>
|
|
77
|
+
|
|
78
|
+
function makeDb() {
|
|
79
|
+
return {
|
|
80
|
+
dbCommunityTool: {
|
|
81
|
+
findUnique: vi.fn().mockResolvedValue(null),
|
|
82
|
+
findFirst: vi.fn().mockResolvedValue(null),
|
|
83
|
+
create: vi.fn().mockResolvedValue(dbTool),
|
|
84
|
+
update: vi.fn().mockResolvedValue(dbTool),
|
|
85
|
+
},
|
|
86
|
+
dbCommunityToolOwner: {
|
|
87
|
+
findMany: vi.fn().mockResolvedValue(dbTool.owners),
|
|
88
|
+
},
|
|
89
|
+
dbCommunityToolView: { count: vi.fn().mockResolvedValue(0) },
|
|
90
|
+
dbPerson: {
|
|
91
|
+
findMany: vi
|
|
92
|
+
.fn()
|
|
93
|
+
.mockResolvedValue([
|
|
94
|
+
{ slug: 'ann@example.com', firstName: 'Ann', lastName: 'Owner' },
|
|
95
|
+
]),
|
|
96
|
+
},
|
|
97
|
+
dbFeedback: {
|
|
98
|
+
create: vi.fn().mockResolvedValue({}),
|
|
99
|
+
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
|
100
|
+
},
|
|
101
|
+
dbResource: { findMany: vi.fn().mockResolvedValue([]) },
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
db = makeDb()
|
|
107
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue(
|
|
108
|
+
db as unknown as PrismaClient,
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
vi.restoreAllMocks()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('caps a tag value at 32 characters', () => {
|
|
117
|
+
const tagged = (value: string) =>
|
|
118
|
+
write.toolInputSchema.safeParse({
|
|
119
|
+
...input,
|
|
120
|
+
tags: [{ kind: 'topic', value }],
|
|
121
|
+
}).success
|
|
122
|
+
expect(tagged('a'.repeat(32))).toBe(true)
|
|
123
|
+
expect(tagged('a'.repeat(33))).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('add stores a topic normalised, and two spellings of it once', async () => {
|
|
127
|
+
db.dbCommunityTool.findUnique
|
|
128
|
+
.mockResolvedValueOnce(null) // the slug is free
|
|
129
|
+
.mockResolvedValue(dbTool)
|
|
130
|
+
const parsed = write.toolInputSchema.parse({
|
|
131
|
+
...input,
|
|
132
|
+
tags: [
|
|
133
|
+
{ kind: 'topic', value: 'Plate Maps' },
|
|
134
|
+
{ kind: 'topic', value: ' plate-maps ' },
|
|
135
|
+
],
|
|
136
|
+
})
|
|
137
|
+
await write.addTool(
|
|
138
|
+
parsed,
|
|
139
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
140
|
+
noCapture,
|
|
141
|
+
NOW,
|
|
142
|
+
)
|
|
143
|
+
expect(db.dbCommunityTool.create).toHaveBeenCalledWith(
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
data: expect.objectContaining({
|
|
146
|
+
tags: { create: [{ kind: 'topic', value: 'plate-maps' }] },
|
|
147
|
+
}),
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('add stores an owner outside the directory by name', async () => {
|
|
153
|
+
db.dbCommunityTool.findUnique
|
|
154
|
+
.mockResolvedValueOnce(null) // the slug is free
|
|
155
|
+
.mockResolvedValue(dbTool)
|
|
156
|
+
await write.addTool(
|
|
157
|
+
write.toolInputSchema.parse({
|
|
158
|
+
...input,
|
|
159
|
+
ownerNames: [' Kim Outside ', 'Kim Outside'],
|
|
160
|
+
}),
|
|
161
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
162
|
+
noCapture,
|
|
163
|
+
NOW,
|
|
164
|
+
)
|
|
165
|
+
expect(db.dbCommunityTool.create).toHaveBeenCalledWith(
|
|
166
|
+
expect.objectContaining({
|
|
167
|
+
data: expect.objectContaining({
|
|
168
|
+
owners: { create: [{ personSlug: 'ann@example.com' }] },
|
|
169
|
+
namedOwners: { create: [{ name: 'Kim Outside' }] },
|
|
170
|
+
}),
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('add takes named owners alone, but one owner at least and five in all', async () => {
|
|
176
|
+
const writer = { user: ann, isAdmin: false, actor: actorFor('user-1') }
|
|
177
|
+
db.dbCommunityTool.findUnique.mockResolvedValue(dbTool)
|
|
178
|
+
const add = (ownerSlugs: string[], ownerNames: string[]) =>
|
|
179
|
+
write.addTool(
|
|
180
|
+
{ ...input, ownerSlugs, ownerNames },
|
|
181
|
+
writer,
|
|
182
|
+
noCapture,
|
|
183
|
+
NOW,
|
|
184
|
+
)
|
|
185
|
+
await expect(add([], [])).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
186
|
+
await expect(
|
|
187
|
+
add(['ann@example.com'], ['A', 'B', 'C', 'D', 'E']),
|
|
188
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
189
|
+
expect(db.dbCommunityTool.create).not.toHaveBeenCalled()
|
|
190
|
+
db.dbCommunityTool.findUnique.mockResolvedValueOnce(null)
|
|
191
|
+
db.dbPerson.findMany.mockResolvedValueOnce([])
|
|
192
|
+
await add([], ['Kim Outside'])
|
|
193
|
+
expect(db.dbCommunityTool.create).toHaveBeenCalled()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('an edit without named owners keeps them; a list replaces them', async () => {
|
|
197
|
+
const named = {
|
|
198
|
+
...dbTool,
|
|
199
|
+
namedOwners: [{ toolId: 'tool-1', name: 'Kim Outside' }],
|
|
200
|
+
}
|
|
201
|
+
db.dbCommunityTool.findUnique.mockResolvedValue(named)
|
|
202
|
+
const writer = { user: ann, isAdmin: false, actor: actorFor('user-1') }
|
|
203
|
+
const edited = (data: unknown) =>
|
|
204
|
+
(data as { data: { namedOwners: unknown; summary: string } }).data
|
|
205
|
+
|
|
206
|
+
await write.editTool(
|
|
207
|
+
write.toolInputSchema.extend({ slug: z.string() }).parse({
|
|
208
|
+
...input,
|
|
209
|
+
ownerNames: undefined,
|
|
210
|
+
summary: 'New',
|
|
211
|
+
slug: 'my-tool',
|
|
212
|
+
}),
|
|
213
|
+
writer,
|
|
214
|
+
noCapture,
|
|
215
|
+
NOW,
|
|
216
|
+
)
|
|
217
|
+
expect(edited(db.dbCommunityTool.update.mock.calls[0]?.[0])).toMatchObject({
|
|
218
|
+
summary: 'New',
|
|
219
|
+
namedOwners: { deleteMany: {}, create: [{ name: 'Kim Outside' }] },
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
await write.editTool(
|
|
223
|
+
{ ...input, ownerNames: [], slug: 'my-tool' },
|
|
224
|
+
writer,
|
|
225
|
+
noCapture,
|
|
226
|
+
NOW,
|
|
227
|
+
)
|
|
228
|
+
expect(edited(db.dbCommunityTool.update.mock.calls[1]?.[0])).toMatchObject({
|
|
229
|
+
namedOwners: { deleteMany: {}, create: [] },
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('add rejects non-http url', async () => {
|
|
234
|
+
await expect(
|
|
235
|
+
write.addTool(
|
|
236
|
+
{ ...input, url: 'javascript:alert(1)' },
|
|
237
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
238
|
+
noCapture,
|
|
239
|
+
NOW,
|
|
240
|
+
),
|
|
241
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
242
|
+
expect(db.dbCommunityTool.create).not.toHaveBeenCalled()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('add writes a pending review row', async () => {
|
|
246
|
+
db.dbCommunityTool.findUnique
|
|
247
|
+
.mockResolvedValueOnce(null) // the slug is free
|
|
248
|
+
.mockResolvedValue(dbTool)
|
|
249
|
+
|
|
250
|
+
const tool = await write.addTool(
|
|
251
|
+
input,
|
|
252
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
253
|
+
noCapture,
|
|
254
|
+
NOW,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
expect(tool.slug).toBe('my-tool')
|
|
258
|
+
expect(db.dbFeedback.create).toHaveBeenCalledWith({
|
|
259
|
+
data: {
|
|
260
|
+
communityToolId: 'tool-1',
|
|
261
|
+
kind: 'marker',
|
|
262
|
+
body: 'New tool added: My Tool',
|
|
263
|
+
status: null,
|
|
264
|
+
authorHash: 'hash-user-1',
|
|
265
|
+
authorAlias: 'Calm Otter',
|
|
266
|
+
authorUserId: 'user-1',
|
|
267
|
+
authorName: 'Ann Owner',
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('add suffixes the slug on collision', async () => {
|
|
273
|
+
db.dbCommunityTool.findUnique
|
|
274
|
+
.mockResolvedValueOnce(dbTool) // my-tool taken
|
|
275
|
+
.mockResolvedValueOnce(null) // my-tool-2 free
|
|
276
|
+
.mockResolvedValue(dbTool)
|
|
277
|
+
|
|
278
|
+
await write.addTool(
|
|
279
|
+
input,
|
|
280
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
281
|
+
noCapture,
|
|
282
|
+
NOW,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
expect(db.dbCommunityTool.create.mock.calls[0]?.[0].data.slug).toBe(
|
|
286
|
+
'my-tool-2',
|
|
287
|
+
)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('add rejects an owner missing from the person directory', async () => {
|
|
291
|
+
await expect(
|
|
292
|
+
write.addTool(
|
|
293
|
+
{ ...input, ownerSlugs: ['ann@example.com', 'ghost@example.com'] },
|
|
294
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
295
|
+
noCapture,
|
|
296
|
+
NOW,
|
|
297
|
+
),
|
|
298
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('edit by stranger is FORBIDDEN', async () => {
|
|
302
|
+
db.dbCommunityTool.findUnique.mockResolvedValue(dbTool)
|
|
303
|
+
|
|
304
|
+
await expect(
|
|
305
|
+
write.editTool(
|
|
306
|
+
{ slug: 'my-tool', ...input, name: 'Hijacked' },
|
|
307
|
+
{ user: bob, isAdmin: false, actor: actorFor('user-2') },
|
|
308
|
+
noCapture,
|
|
309
|
+
NOW,
|
|
310
|
+
),
|
|
311
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' })
|
|
312
|
+
expect(db.dbCommunityTool.update).not.toHaveBeenCalled()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('edit by owner names the changed fields in the review row', async () => {
|
|
316
|
+
db.dbCommunityTool.findUnique.mockResolvedValue(dbTool)
|
|
317
|
+
|
|
318
|
+
await write.editTool(
|
|
319
|
+
{ slug: 'my-tool', ...input, summary: 'Does two things' },
|
|
320
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
321
|
+
noCapture,
|
|
322
|
+
NOW,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
expect(db.dbFeedback.create.mock.calls[0]?.[0].data.body).toBe(
|
|
326
|
+
'Tool edited: summary',
|
|
327
|
+
)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('edit puts an approved tool back in the review queue', async () => {
|
|
331
|
+
db.dbCommunityTool.findUnique.mockResolvedValue({
|
|
332
|
+
...dbTool,
|
|
333
|
+
reviewedAt: NOW,
|
|
334
|
+
reviewedByName: 'Ada Admin',
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
await write.editTool(
|
|
338
|
+
{ slug: 'my-tool', ...input, summary: 'Does two things' },
|
|
339
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
340
|
+
noCapture,
|
|
341
|
+
NOW,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
expect(db.dbCommunityTool.update.mock.calls[0]?.[0].data).toMatchObject({
|
|
345
|
+
reviewedAt: null,
|
|
346
|
+
reviewedByName: null,
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('edit to another host starts its health over and lifts an automatic archive', async () => {
|
|
351
|
+
db.dbCommunityTool.findUnique.mockResolvedValue({
|
|
352
|
+
...dbTool,
|
|
353
|
+
lastCheckAt: NOW,
|
|
354
|
+
failingSince: NOW,
|
|
355
|
+
archivedAt: NOW,
|
|
356
|
+
archivedBy: 'auto',
|
|
357
|
+
archivedReason: 'Not working for 14 days',
|
|
358
|
+
})
|
|
359
|
+
const capture = vi.fn().mockResolvedValue({ ok: false, error: 'x' })
|
|
360
|
+
|
|
361
|
+
await write.editTool(
|
|
362
|
+
{ slug: 'my-tool', ...input, url: 'https://moved.pages.example.com/' },
|
|
363
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
364
|
+
{ captureHosts: ['pages.example.com'], capturer: { capture } },
|
|
365
|
+
NOW,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
expect(db.dbCommunityTool.update.mock.calls[0]?.[0].data).toMatchObject({
|
|
369
|
+
host: 'moved.pages.example.com',
|
|
370
|
+
failingSince: null,
|
|
371
|
+
lastCheckAt: null,
|
|
372
|
+
archivedAt: null,
|
|
373
|
+
archivedBy: null,
|
|
374
|
+
archivedReason: null,
|
|
375
|
+
})
|
|
376
|
+
await vi.waitFor(() => expect(capture).toHaveBeenCalled())
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('edit on the same host keeps the health record and a person archive', async () => {
|
|
380
|
+
db.dbCommunityTool.findUnique.mockResolvedValue({
|
|
381
|
+
...dbTool,
|
|
382
|
+
failingSince: NOW,
|
|
383
|
+
archivedAt: NOW,
|
|
384
|
+
archivedBy: 'Ann Owner',
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
await write.editTool(
|
|
388
|
+
{
|
|
389
|
+
slug: 'my-tool',
|
|
390
|
+
...input,
|
|
391
|
+
url: 'https://my-tool.pages.example.com/v2',
|
|
392
|
+
},
|
|
393
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
394
|
+
noCapture,
|
|
395
|
+
NOW,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
const data = db.dbCommunityTool.update.mock.calls[0]?.[0].data
|
|
399
|
+
expect(data).not.toHaveProperty('failingSince')
|
|
400
|
+
expect(data).not.toHaveProperty('archivedAt')
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('preview answers TOO_MANY_REQUESTS past three queued captures', async () => {
|
|
404
|
+
const capturer = {
|
|
405
|
+
capture: vi.fn(),
|
|
406
|
+
waiting: () => 4,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
await expect(
|
|
410
|
+
write.previewTool('https://my-tool.pages.example.com/', {
|
|
411
|
+
captureHosts: ['pages.example.com'],
|
|
412
|
+
capturer,
|
|
413
|
+
}),
|
|
414
|
+
).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' })
|
|
415
|
+
expect(capturer.capture).not.toHaveBeenCalled()
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('archive sets archivedBy to the user name', async () => {
|
|
419
|
+
db.dbCommunityTool.findUnique.mockResolvedValue(dbTool)
|
|
420
|
+
|
|
421
|
+
await write.archiveTool(
|
|
422
|
+
'my-tool',
|
|
423
|
+
'Replaced by a better one',
|
|
424
|
+
{ user: ann, isAdmin: false, actor: actorFor('user-1') },
|
|
425
|
+
NOW,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
expect(db.dbCommunityTool.update).toHaveBeenCalledWith({
|
|
429
|
+
where: { id: 'tool-1' },
|
|
430
|
+
data: {
|
|
431
|
+
archivedAt: NOW,
|
|
432
|
+
archivedBy: 'Ann Owner',
|
|
433
|
+
archivedReason: 'Replaced by a better one',
|
|
434
|
+
},
|
|
435
|
+
})
|
|
436
|
+
expect(db.dbFeedback.updateMany).not.toHaveBeenCalled()
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('approve marks review rows applied', async () => {
|
|
440
|
+
db.dbCommunityTool.findUnique.mockResolvedValue(dbTool)
|
|
441
|
+
const admin = mockUser('user-9', 'admin@example.com', 'Ada Admin')
|
|
442
|
+
|
|
443
|
+
await write.approveTool(
|
|
444
|
+
'my-tool',
|
|
445
|
+
{ user: admin, isAdmin: true, actor: actorFor('user-9') },
|
|
446
|
+
NOW,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
expect(db.dbCommunityTool.update).toHaveBeenCalledWith({
|
|
450
|
+
where: { id: 'tool-1' },
|
|
451
|
+
data: { reviewedAt: NOW, reviewedByName: 'Ada Admin' },
|
|
452
|
+
})
|
|
453
|
+
expect(db.dbFeedback.updateMany).toHaveBeenCalledWith({
|
|
454
|
+
where: { communityToolId: 'tool-1', status: null },
|
|
455
|
+
data: { status: 'applied', reviewedAt: NOW },
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('preview reports duplicate by host for a different path', async () => {
|
|
460
|
+
db.dbCommunityTool.findFirst.mockResolvedValue({
|
|
461
|
+
slug: 'my-tool',
|
|
462
|
+
name: 'My Tool',
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
const preview = await write.previewTool(
|
|
466
|
+
'https://my-tool.pages.example.com/other/path',
|
|
467
|
+
noCapture,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
expect(db.dbCommunityTool.findFirst).toHaveBeenCalledWith({
|
|
471
|
+
where: { host: 'my-tool.pages.example.com' },
|
|
472
|
+
select: { slug: true, name: true },
|
|
473
|
+
})
|
|
474
|
+
expect(preview.duplicate).toEqual({ slug: 'my-tool', name: 'My Tool' })
|
|
475
|
+
expect(preview.host).toBe('my-tool.pages.example.com')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('preview skips capture for disallowed host', async () => {
|
|
479
|
+
const capturer: ScreenshotCapturer = { capture: vi.fn() }
|
|
480
|
+
|
|
481
|
+
const preview = await write.previewTool('https://elsewhere.example.org/', {
|
|
482
|
+
captureHosts: ['pages.example.com'],
|
|
483
|
+
capturer,
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
expect(capturer.capture).not.toHaveBeenCalled()
|
|
487
|
+
expect(preview).toEqual({
|
|
488
|
+
host: 'elsewhere.example.org',
|
|
489
|
+
title: null,
|
|
490
|
+
screenshotDataUrl: null,
|
|
491
|
+
duplicate: null,
|
|
492
|
+
officialApp: null,
|
|
493
|
+
captureAllowed: false,
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('preview names the official app on the same host, any case', async () => {
|
|
498
|
+
db.dbResource.findMany.mockResolvedValue([
|
|
499
|
+
{
|
|
500
|
+
slug: 'docs',
|
|
501
|
+
displayName: 'Docs',
|
|
502
|
+
appUrl: 'https://docs.example.com/',
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
slug: 'order-service',
|
|
506
|
+
displayName: 'Order Service',
|
|
507
|
+
appUrl: 'https://Orders.pages.example.com/app',
|
|
508
|
+
},
|
|
509
|
+
])
|
|
510
|
+
|
|
511
|
+
const preview = await write.previewTool(
|
|
512
|
+
'https://orders.pages.example.com/today',
|
|
513
|
+
noCapture,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
expect(db.dbResource.findMany).toHaveBeenCalledWith({
|
|
517
|
+
where: { parentSlug: null, appUrl: { not: null } },
|
|
518
|
+
select: { slug: true, displayName: true, appUrl: true },
|
|
519
|
+
})
|
|
520
|
+
expect(preview.officialApp).toEqual({
|
|
521
|
+
slug: 'order-service',
|
|
522
|
+
name: 'Order Service',
|
|
523
|
+
})
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('preview returns a downscaled jpeg for an allowed host', async () => {
|
|
527
|
+
const png = await sharp({
|
|
528
|
+
create: { width: 1280, height: 800, channels: 3, background: '#fff' },
|
|
529
|
+
})
|
|
530
|
+
.png()
|
|
531
|
+
.toBuffer()
|
|
532
|
+
const capturer: ScreenshotCapturer = {
|
|
533
|
+
capture: vi.fn().mockResolvedValue({
|
|
534
|
+
ok: true,
|
|
535
|
+
png,
|
|
536
|
+
title: 'Page title',
|
|
537
|
+
favicon: null,
|
|
538
|
+
lastModified: null,
|
|
539
|
+
}),
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const preview = await write.previewTool(
|
|
543
|
+
'https://my-tool.pages.example.com/',
|
|
544
|
+
{ captureHosts: ['pages.example.com'], capturer },
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
expect(capturer.capture).toHaveBeenCalledWith(
|
|
548
|
+
'https://my-tool.pages.example.com/',
|
|
549
|
+
['pages.example.com'],
|
|
550
|
+
)
|
|
551
|
+
expect(preview.title).toBe('Page title')
|
|
552
|
+
expect(preview.captureAllowed).toBe(true)
|
|
553
|
+
const jpeg = Buffer.from(
|
|
554
|
+
preview.screenshotDataUrl!.replace('data:image/jpeg;base64,', ''),
|
|
555
|
+
'base64',
|
|
556
|
+
)
|
|
557
|
+
const meta = await sharp(jpeg).metadata()
|
|
558
|
+
expect(meta.format).toBe('jpeg')
|
|
559
|
+
expect(meta.width).toBe(640)
|
|
560
|
+
})
|
|
561
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import * as dbClient from '../db/client'
|
|
4
|
+
import type { PrismaClient } from '../generated/prisma/client'
|
|
5
|
+
import {
|
|
6
|
+
addFeedback,
|
|
7
|
+
dismissFeedback,
|
|
8
|
+
editFeedback,
|
|
9
|
+
listFeedback,
|
|
10
|
+
} from '../modules/feedback/service'
|
|
11
|
+
|
|
12
|
+
const NOW = new Date('2026-10-21T12:00:00.000Z')
|
|
13
|
+
const actor = {
|
|
14
|
+
hash: 'hash-user-1',
|
|
15
|
+
publicId: 'pub-user-1',
|
|
16
|
+
alias: 'Calm Otter',
|
|
17
|
+
kind: 'user' as const,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function row(fields: Record<string, unknown> = {}) {
|
|
21
|
+
return {
|
|
22
|
+
id: 'fb-1',
|
|
23
|
+
authorHash: 'hash-user-1',
|
|
24
|
+
authorAlias: 'Calm Otter',
|
|
25
|
+
authorName: null,
|
|
26
|
+
body: 'hi',
|
|
27
|
+
subject: null,
|
|
28
|
+
kind: null,
|
|
29
|
+
status: null,
|
|
30
|
+
reviewerReply: null,
|
|
31
|
+
reviewedAt: null,
|
|
32
|
+
editedAt: null,
|
|
33
|
+
createdAt: NOW,
|
|
34
|
+
resource: null,
|
|
35
|
+
attachments: [],
|
|
36
|
+
...fields,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Feedback service', () => {
|
|
41
|
+
let db: ReturnType<typeof makeDb>
|
|
42
|
+
|
|
43
|
+
function makeDb() {
|
|
44
|
+
const d = {
|
|
45
|
+
$transaction: vi.fn((fn: (tx: unknown) => Promise<unknown>) => fn(d)),
|
|
46
|
+
dbCommunityTool: {
|
|
47
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'tool-1' }),
|
|
48
|
+
},
|
|
49
|
+
dbResource: { findUnique: vi.fn().mockResolvedValue(null) },
|
|
50
|
+
dbFeedback: {
|
|
51
|
+
create: vi.fn().mockResolvedValue({ id: 'fb-1' }),
|
|
52
|
+
findUniqueOrThrow: vi.fn(),
|
|
53
|
+
findMany: vi.fn().mockResolvedValue([]),
|
|
54
|
+
findUnique: vi.fn(),
|
|
55
|
+
update: vi.fn(),
|
|
56
|
+
delete: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
dbFeedbackAttachment: { updateMany: vi.fn() },
|
|
59
|
+
}
|
|
60
|
+
return d
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
db = makeDb()
|
|
65
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue(
|
|
66
|
+
db as unknown as PrismaClient,
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.restoreAllMocks()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('comment by signed-in user stores authorName', async () => {
|
|
75
|
+
db.dbFeedback.findUniqueOrThrow.mockResolvedValue(
|
|
76
|
+
row({ authorName: 'Ann Owner' }),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const view = await addFeedback(
|
|
80
|
+
{ communityToolSlug: 'my-tool', body: 'hi' },
|
|
81
|
+
actor,
|
|
82
|
+
{ id: 'user-1', name: 'Ann Owner' },
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(db.dbFeedback.create.mock.calls[0]?.[0].data).toMatchObject({
|
|
86
|
+
resourceId: null,
|
|
87
|
+
communityToolId: 'tool-1',
|
|
88
|
+
authorUserId: 'user-1',
|
|
89
|
+
authorName: 'Ann Owner',
|
|
90
|
+
subject: null,
|
|
91
|
+
})
|
|
92
|
+
expect(view.authorName).toBe('Ann Owner')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('anonymous comment keeps the alias as its name', async () => {
|
|
96
|
+
db.dbFeedback.findUniqueOrThrow.mockResolvedValue(row())
|
|
97
|
+
|
|
98
|
+
const view = await addFeedback(
|
|
99
|
+
{ communityToolSlug: 'my-tool', body: 'hi' },
|
|
100
|
+
{ ...actor, kind: 'visitor' },
|
|
101
|
+
null,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
expect(db.dbFeedback.create.mock.calls[0]?.[0].data).toMatchObject({
|
|
105
|
+
authorUserId: null,
|
|
106
|
+
authorName: null,
|
|
107
|
+
})
|
|
108
|
+
expect(view.authorName).toBe('Calm Otter')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('lists a community tool thread', async () => {
|
|
112
|
+
db.dbFeedback.findMany.mockResolvedValue([row()])
|
|
113
|
+
|
|
114
|
+
const list = await listFeedback({ communityToolSlug: 'my-tool' }, actor)
|
|
115
|
+
|
|
116
|
+
expect(db.dbFeedback.findMany.mock.calls[0]?.[0].where).toEqual({
|
|
117
|
+
communityToolId: 'tool-1',
|
|
118
|
+
})
|
|
119
|
+
expect(list.openCount).toBe(1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe("a community tool's review marker", () => {
|
|
123
|
+
const marker = row({ kind: 'marker', body: 'New tool added: My Tool' })
|
|
124
|
+
|
|
125
|
+
it('is a system line, not the publisher’s own note', async () => {
|
|
126
|
+
db.dbFeedback.findMany.mockResolvedValue([marker])
|
|
127
|
+
|
|
128
|
+
const { items } = await listFeedback(
|
|
129
|
+
{ communityToolSlug: 'my-tool' },
|
|
130
|
+
actor,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
expect(items[0]).toMatchObject({
|
|
134
|
+
isSystem: true,
|
|
135
|
+
isMine: false,
|
|
136
|
+
canEditUntil: null,
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('cannot be withdrawn by the publisher', async () => {
|
|
141
|
+
db.dbFeedback.findUnique.mockResolvedValue(marker)
|
|
142
|
+
|
|
143
|
+
await expect(dismissFeedback('fb-1', actor)).rejects.toMatchObject({
|
|
144
|
+
reason: 'not-author',
|
|
145
|
+
})
|
|
146
|
+
expect(db.dbFeedback.delete).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('cannot be edited by the publisher', async () => {
|
|
150
|
+
db.dbFeedback.findUnique.mockResolvedValue(marker)
|
|
151
|
+
|
|
152
|
+
await expect(
|
|
153
|
+
editFeedback('fb-1', 'rewritten', actor),
|
|
154
|
+
).rejects.toMatchObject({
|
|
155
|
+
reason: 'not-author',
|
|
156
|
+
})
|
|
157
|
+
expect(db.dbFeedback.update).not.toHaveBeenCalled()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
})
|