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