@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.
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 +252 -0
  50. package/dist/modules/communityTools/service.mjs.map +1 -0
  51. package/dist/modules/communityTools/toView.mjs +81 -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 +781 -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 +561 -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 +380 -0
  100. package/src/modules/communityTools/toView.ts +112 -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,319 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import type { CapturePage } from '../modules/communityTools/capture'
4
+ import {
5
+ allowRequest,
6
+ capturePage,
7
+ chromiumFenceArgs,
8
+ fenceRoute,
9
+ followWithinHosts,
10
+ oneAtATime,
11
+ } from '../modules/communityTools/capture'
12
+
13
+ const HOSTS = ['pages.example.com']
14
+ const URL_OK = 'https://my-tool.pages.example.com/'
15
+
16
+ function fakePage(fields: {
17
+ landedOn?: string
18
+ iconHref?: string | null
19
+ headers?: Record<string, string>
20
+ }) {
21
+ const iconBody = Buffer.from('icon')
22
+ const page = {
23
+ setDefaultTimeout: vi.fn(),
24
+ goto: vi.fn().mockResolvedValue({
25
+ ok: () => true,
26
+ status: () => 200,
27
+ headers: () => fields.headers ?? {},
28
+ }),
29
+ waitForLoadState: vi.fn().mockResolvedValue(undefined),
30
+ waitForTimeout: vi.fn().mockResolvedValue(undefined),
31
+ url: vi.fn(() => fields.landedOn ?? URL_OK),
32
+ screenshot: vi.fn().mockResolvedValue(Buffer.from('png')),
33
+ title: vi.fn().mockResolvedValue(' My Tool '),
34
+ evaluate: vi.fn().mockResolvedValue(fields.iconHref ?? null),
35
+ request: {
36
+ get: vi.fn().mockResolvedValue({
37
+ ok: () => true,
38
+ body: () => Promise.resolve(iconBody),
39
+ }),
40
+ },
41
+ }
42
+ return { page: page satisfies CapturePage, iconBody }
43
+ }
44
+
45
+ describe('Community tools capture', () => {
46
+ it('fails a page that redirected off the allowed hosts', async () => {
47
+ const { page } = fakePage({ landedOn: 'https://intranet.example.org/' })
48
+
49
+ const result = await capturePage(page, URL_OK, HOSTS)
50
+
51
+ expect(result).toEqual({
52
+ ok: false,
53
+ error: 'redirected off the allowed hosts',
54
+ })
55
+ expect(page.screenshot).not.toHaveBeenCalled()
56
+ })
57
+
58
+ it('skips the favicon fetch for an off-list href', async () => {
59
+ const { page } = fakePage({
60
+ iconHref: 'https://intranet.example.org/x.ico',
61
+ })
62
+
63
+ const result = await capturePage(page, URL_OK, HOSTS)
64
+
65
+ expect(page.request.get).not.toHaveBeenCalled()
66
+ expect(result).toEqual({
67
+ ok: true,
68
+ png: Buffer.from('png'),
69
+ title: 'My Tool',
70
+ favicon: null,
71
+ lastModified: null,
72
+ })
73
+ })
74
+
75
+ it('fetches an on-list favicon without following redirects', async () => {
76
+ const { page, iconBody } = fakePage({})
77
+
78
+ const result = await capturePage(page, URL_OK, HOSTS)
79
+
80
+ expect(page.request.get).toHaveBeenCalledWith(
81
+ 'https://my-tool.pages.example.com/favicon.ico',
82
+ { timeout: 5_000, maxRedirects: 0 },
83
+ )
84
+ expect(result.ok && result.favicon).toEqual(iconBody)
85
+ })
86
+
87
+ it('bounds every page call with the capture timeout', async () => {
88
+ const { page } = fakePage({})
89
+
90
+ await capturePage(page, URL_OK, HOSTS)
91
+
92
+ expect(page.setDefaultTimeout).toHaveBeenCalledWith(20_000)
93
+ })
94
+
95
+ it('photographs a page once its data has loaded and rendered', async () => {
96
+ const { page } = fakePage({})
97
+
98
+ await capturePage(page, URL_OK, HOSTS)
99
+
100
+ expect(page.waitForLoadState).toHaveBeenCalledWith('networkidle', {
101
+ timeout: expect.any(Number),
102
+ })
103
+ expect(page.waitForTimeout).toHaveBeenCalledWith(1_500)
104
+ const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
105
+ fn.mock.invocationCallOrder[0]!
106
+ expect(order(page.goto)).toBeLessThan(order(page.waitForLoadState))
107
+ expect(order(page.waitForLoadState)).toBeLessThan(
108
+ order(page.waitForTimeout),
109
+ )
110
+ expect(order(page.waitForTimeout)).toBeLessThan(order(page.screenshot))
111
+ })
112
+
113
+ it('reads when the site last changed from its Last-Modified', async () => {
114
+ const { page } = fakePage({
115
+ headers: { 'last-modified': 'Wed, 21 Oct 2026 07:28:00 GMT' },
116
+ })
117
+
118
+ const result = await capturePage(page, URL_OK, HOSTS)
119
+
120
+ expect(result.ok && result.lastModified).toEqual(
121
+ new Date('2026-10-21T07:28:00Z'),
122
+ )
123
+ })
124
+
125
+ it('has no change date for a page without, or with a garbled, Last-Modified', async () => {
126
+ const cases: Record<string, string>[] = [{}, { 'last-modified': 'soon' }]
127
+ for (const headers of cases) {
128
+ const { page } = fakePage({ headers })
129
+ const result = await capturePage(page, URL_OK, HOSTS)
130
+ expect(result.ok && result.lastModified).toBeNull()
131
+ }
132
+ })
133
+
134
+ it('photographs a page that never goes quiet', async () => {
135
+ const { page } = fakePage({})
136
+ page.waitForLoadState.mockRejectedValue(new Error('Timeout exceeded'))
137
+
138
+ const result = await capturePage(page, URL_OK, HOSTS)
139
+
140
+ expect(result.ok).toBe(true)
141
+ })
142
+
143
+ it('keeps the waits inside the one capture timeout', async () => {
144
+ const { page } = fakePage({})
145
+ const start = 1_000_000
146
+ const now = vi.spyOn(Date, 'now').mockReturnValue(start)
147
+ // The page took 19 of the 20 seconds to load.
148
+ page.goto.mockImplementation(() => {
149
+ now.mockReturnValue(start + 19_000)
150
+ return Promise.resolve({
151
+ ok: () => true,
152
+ status: () => 200,
153
+ headers: () => ({}),
154
+ })
155
+ })
156
+
157
+ try {
158
+ await capturePage(page, URL_OK, HOSTS)
159
+ } finally {
160
+ now.mockRestore()
161
+ }
162
+
163
+ expect(page.waitForLoadState).not.toHaveBeenCalled()
164
+ expect(page.waitForTimeout).toHaveBeenCalledWith(1_000)
165
+ })
166
+
167
+ it('lets only allowed hosts and inline urls through the browser', () => {
168
+ expect(allowRequest('https://cdn.pages.example.com/a.js', HOSTS)).toBe(true)
169
+ expect(allowRequest('data:image/png;base64,AAAA', HOSTS)).toBe(true)
170
+ expect(
171
+ allowRequest('blob:https://my-tool.pages.example.com/1', HOSTS),
172
+ ).toBe(true)
173
+ expect(allowRequest('http://169.254.169.254/latest/', HOSTS)).toBe(false)
174
+ expect(allowRequest('https://intranet.example.org/', HOSTS)).toBe(false)
175
+ })
176
+
177
+ it('never requests a redirect target off the allowed hosts', async () => {
178
+ const fetch = vi.fn().mockResolvedValue({
179
+ status: () => 302,
180
+ headers: () => ({ location: 'http://169.254.169.254/latest/' }),
181
+ })
182
+
183
+ const response = await followWithinHosts(fetch, URL_OK, HOSTS)
184
+
185
+ expect(response).toBeNull()
186
+ expect(fetch).toHaveBeenCalledTimes(1)
187
+ })
188
+
189
+ it('follows a redirect that stays on the allowed hosts', async () => {
190
+ const final = { status: () => 200, headers: () => ({}) }
191
+ const fetch = vi
192
+ .fn()
193
+ .mockResolvedValueOnce({
194
+ status: () => 301,
195
+ headers: () => ({ location: '/moved' }),
196
+ })
197
+ .mockResolvedValue(final)
198
+
199
+ const response = await followWithinHosts(fetch, URL_OK, HOSTS)
200
+
201
+ expect(response).toBe(final)
202
+ expect(fetch).toHaveBeenLastCalledWith(
203
+ 'https://my-tool.pages.example.com/moved',
204
+ 'get',
205
+ )
206
+ })
207
+
208
+ it('keeps the method on 307 and 308, switches to GET on 301-303', async () => {
209
+ const hop = (status: number) => ({
210
+ status: () => status,
211
+ headers: () => ({ location: `/after-${status}` }),
212
+ })
213
+ const fetch = vi
214
+ .fn()
215
+ .mockResolvedValueOnce(hop(307))
216
+ .mockResolvedValueOnce(hop(303))
217
+ .mockResolvedValue({ status: () => 200, headers: () => ({}) })
218
+
219
+ await followWithinHosts(fetch, URL_OK, HOSTS)
220
+
221
+ expect(fetch.mock.calls.map((c) => c[1])).toEqual(['same', 'same', 'get'])
222
+ })
223
+
224
+ it('aborts a request whose fetch fails instead of crashing', async () => {
225
+ const route = {
226
+ url: 'https://dead.pages.example.com/img.png',
227
+ abort: vi.fn().mockResolvedValue(undefined),
228
+ continue: vi.fn(),
229
+ fetch: vi.fn().mockRejectedValue(new Error('getaddrinfo ENOTFOUND')),
230
+ fulfill: vi.fn(),
231
+ }
232
+
233
+ await expect(fenceRoute(route, HOSTS)).resolves.toBeUndefined()
234
+
235
+ expect(route.abort).toHaveBeenCalledWith('failed')
236
+ expect(route.fulfill).not.toHaveBeenCalled()
237
+ })
238
+
239
+ it('aborts an off-list request without fetching it', async () => {
240
+ const route = {
241
+ url: 'https://intranet.example.org/',
242
+ abort: vi.fn().mockResolvedValue(undefined),
243
+ continue: vi.fn(),
244
+ fetch: vi.fn(),
245
+ fulfill: vi.fn(),
246
+ }
247
+
248
+ await fenceRoute(route, HOSTS)
249
+
250
+ expect(route.abort).toHaveBeenCalledWith('blockedbyclient')
251
+ expect(route.fetch).not.toHaveBeenCalled()
252
+ })
253
+
254
+ it('fences the browser network stack to the capture hosts', () => {
255
+ expect(chromiumFenceArgs(['pages.example.com', 'example.net'])).toEqual([
256
+ '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE *.pages.example.com, EXCLUDE pages.example.com, EXCLUDE *.example.net, EXCLUDE example.net',
257
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
258
+ '--webrtc-ip-handling-policy=disable_non_proxied_udp',
259
+ ])
260
+ })
261
+
262
+ it('drops a host that could inject resolver rules', () => {
263
+ expect(chromiumFenceArgs(['example.com, EXCLUDE *'])[0]).toBe(
264
+ '--host-resolver-rules=MAP * ~NOTFOUND',
265
+ )
266
+ })
267
+
268
+ it('runs two concurrent captures one after the other', async () => {
269
+ let running = 0
270
+ let maxRunning = 0
271
+ const slow = vi.fn(async () => {
272
+ running++
273
+ maxRunning = Math.max(maxRunning, running)
274
+ await new Promise((r) => setTimeout(r, 10))
275
+ running--
276
+ return { ok: false as const, error: 'x' }
277
+ })
278
+ const { capture } = oneAtATime(slow)
279
+
280
+ await Promise.all([capture(URL_OK, HOSTS), capture(URL_OK, HOSTS)])
281
+
282
+ expect(slow).toHaveBeenCalledTimes(2)
283
+ expect(maxRunning).toBe(1)
284
+ })
285
+
286
+ it('counts the captures queued behind the running one', async () => {
287
+ let release = () => {}
288
+ const queue = oneAtATime(
289
+ () =>
290
+ new Promise((resolve) => {
291
+ release = () => resolve({ ok: false, error: 'x' })
292
+ }),
293
+ )
294
+
295
+ const runs = [1, 2, 3].map(() => queue.capture(URL_OK, HOSTS))
296
+ await vi.waitFor(() => expect(queue.waiting()).toBe(2))
297
+ release()
298
+ await vi.waitFor(() => expect(queue.waiting()).toBe(1))
299
+ release()
300
+ await vi.waitFor(() => expect(queue.waiting()).toBe(0))
301
+ release()
302
+ await Promise.all(runs)
303
+ })
304
+
305
+ it('keeps the queue moving after a capture throws', async () => {
306
+ const { capture } = oneAtATime(
307
+ vi
308
+ .fn()
309
+ .mockRejectedValueOnce(new Error('launch failed'))
310
+ .mockResolvedValue({ ok: false, error: 'x' }),
311
+ )
312
+
313
+ await expect(capture(URL_OK, HOSTS)).rejects.toThrow('launch failed')
314
+ await expect(capture(URL_OK, HOSTS)).resolves.toEqual({
315
+ ok: false,
316
+ error: 'x',
317
+ })
318
+ })
319
+ })
@@ -0,0 +1,154 @@
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 type { ScreenshotCapturer } from '../modules/communityTools/capture'
6
+ import {
7
+ runHealthCheckOnce,
8
+ startHealthJob,
9
+ } from '../modules/communityTools/healthJob'
10
+
11
+ const NOW = new Date('2026-10-21T12:00:00.000Z')
12
+ const DAY = 24 * 60 * 60 * 1000
13
+
14
+ function tool(id: string, fields: Record<string, unknown> = {}) {
15
+ return {
16
+ id,
17
+ slug: id,
18
+ url: `https://${id}.pages.example.com/`,
19
+ lastCheckAt: new Date(NOW.getTime() - 2 * DAY),
20
+ failingSince: null,
21
+ archivedAt: null,
22
+ archivedBy: null,
23
+ ...fields,
24
+ }
25
+ }
26
+
27
+ describe('Community tools health job', () => {
28
+ let db: ReturnType<typeof makeDb>
29
+ let locked: boolean
30
+
31
+ function makeDb() {
32
+ const tx = {
33
+ $queryRaw: vi.fn(() => Promise.resolve([{ locked }])),
34
+ }
35
+ return {
36
+ tx,
37
+ $transaction: vi.fn((fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
38
+ dbCommunityTool: {
39
+ findMany: vi.fn().mockResolvedValue([]),
40
+ update: vi.fn().mockResolvedValue({}),
41
+ },
42
+ }
43
+ }
44
+
45
+ const failing: ScreenshotCapturer = {
46
+ capture: vi.fn().mockResolvedValue({ ok: false, error: 'HTTP 503' }),
47
+ }
48
+ const opts = { captureHosts: ['pages.example.com'], capturer: failing }
49
+
50
+ beforeEach(() => {
51
+ locked = true
52
+ db = makeDb()
53
+ vi.spyOn(dbClient, 'getDbClient').mockReturnValue(
54
+ db as unknown as PrismaClient,
55
+ )
56
+ })
57
+
58
+ afterEach(() => {
59
+ vi.clearAllMocks()
60
+ vi.restoreAllMocks()
61
+ })
62
+
63
+ it('health run applies nextLifecycle and skips person-archived', async () => {
64
+ db.dbCommunityTool.findMany.mockResolvedValue([
65
+ tool('due'),
66
+ tool('person-archived', { archivedAt: NOW, archivedBy: 'Ann Owner' }),
67
+ tool('fresh', { lastCheckAt: new Date(NOW.getTime() - 60 * 60 * 1000) }),
68
+ tool('other-host', { url: 'https://elsewhere.example.org/' }),
69
+ ])
70
+
71
+ await runHealthCheckOnce(opts, NOW)
72
+
73
+ expect(failing.capture).toHaveBeenCalledTimes(1)
74
+ expect(failing.capture).toHaveBeenCalledWith(
75
+ 'https://due.pages.example.com/',
76
+ ['pages.example.com'],
77
+ )
78
+ expect(db.dbCommunityTool.update).toHaveBeenCalledTimes(1)
79
+ expect(db.dbCommunityTool.update).toHaveBeenCalledWith({
80
+ where: { id: 'due' },
81
+ data: { lastCheckAt: NOW, failingSince: NOW },
82
+ })
83
+ })
84
+
85
+ it('health run continues with the next tool after an error', async () => {
86
+ db.dbCommunityTool.findMany.mockResolvedValue([tool('a'), tool('b')])
87
+ const flaky: ScreenshotCapturer = {
88
+ capture: vi
89
+ .fn()
90
+ .mockRejectedValueOnce(new Error('browser crashed'))
91
+ .mockResolvedValue({ ok: false, error: 'HTTP 503' }),
92
+ }
93
+ vi.spyOn(console, 'error').mockImplementation(() => {})
94
+
95
+ await runHealthCheckOnce({ ...opts, capturer: flaky }, NOW)
96
+
97
+ // a: the attempt is recorded and nothing else; b: the failed page.
98
+ expect(db.dbCommunityTool.update.mock.calls.map((c) => c[0])).toEqual([
99
+ { where: { id: 'a' }, data: { lastCheckAt: NOW } },
100
+ { where: { id: 'b' }, data: { lastCheckAt: NOW, failingSince: NOW } },
101
+ ])
102
+ })
103
+
104
+ it('health run stops once its time budget is spent', async () => {
105
+ db.dbCommunityTool.findMany.mockResolvedValue([
106
+ tool('a'),
107
+ tool('b'),
108
+ tool('c'),
109
+ ])
110
+ const MIN = 60 * 1000
111
+ // Start, then before each tool: a fits, b starts at 41 minutes and is not checked.
112
+ const clock = vi
113
+ .fn()
114
+ .mockReturnValueOnce(0)
115
+ .mockReturnValueOnce(0)
116
+ .mockReturnValue(41 * MIN)
117
+
118
+ await runHealthCheckOnce(opts, NOW, clock)
119
+
120
+ expect(failing.capture).toHaveBeenCalledTimes(1)
121
+ expect(db.dbCommunityTool.update.mock.calls[0]?.[0].where).toEqual({
122
+ id: 'a',
123
+ })
124
+ })
125
+
126
+ it('first run happens a minute after start, then hourly', async () => {
127
+ vi.useFakeTimers()
128
+ try {
129
+ const stop = startHealthJob(opts, () => NOW)
130
+ await vi.advanceTimersByTimeAsync(59_000)
131
+ expect(db.$transaction).not.toHaveBeenCalled()
132
+ await vi.advanceTimersByTimeAsync(1_000)
133
+ expect(db.$transaction).toHaveBeenCalledTimes(1)
134
+ await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
135
+ expect(db.$transaction).toHaveBeenCalledTimes(2)
136
+ stop()
137
+ await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000)
138
+ expect(db.$transaction).toHaveBeenCalledTimes(2)
139
+ } finally {
140
+ vi.useRealTimers()
141
+ }
142
+ })
143
+
144
+ it('health run does nothing when the advisory lock is held', async () => {
145
+ locked = false
146
+ db.dbCommunityTool.findMany.mockResolvedValue([tool('due')])
147
+
148
+ await runHealthCheckOnce(opts, NOW)
149
+
150
+ expect(db.tx.$queryRaw).toHaveBeenCalledTimes(1)
151
+ expect(db.dbCommunityTool.findMany).not.toHaveBeenCalled()
152
+ expect(failing.capture).not.toHaveBeenCalled()
153
+ })
154
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createCommunityToolsRouter } from '../modules/communityTools/router'
3
+ import type { AcTrpcContext } from '../server/acTrpcContext'
4
+
5
+ describe('communityTools router when the feature is off', () => {
6
+ // One shape on and off, so clients are typed for every procedure either way.
7
+ const caller = createCommunityToolsRouter().createCaller({
8
+ user: null,
9
+ isAdmin: false,
10
+ actor: null,
11
+ } as unknown as AcTrpcContext)
12
+
13
+ it('reports itself disabled', async () => {
14
+ expect(await caller.config()).toEqual({ enabled: false })
15
+ })
16
+
17
+ it('answers NOT_FOUND for every other procedure', async () => {
18
+ await expect(caller.list()).rejects.toMatchObject({ code: 'NOT_FOUND' })
19
+ })
20
+ })
21
+
22
+ describe('communityTools router authorization', () => {
23
+ const routerOn = createCommunityToolsRouter({ enabled: true })
24
+ const callerAs = (ctx: { user: { id: string } | null; isAdmin: boolean }) =>
25
+ routerOn.createCaller({ ...ctx, actor: null } as unknown as AcTrpcContext)
26
+ const anonymous = callerAs({ user: null, isAdmin: false })
27
+ const tool = {
28
+ url: 'https://my-tool.pages.example.com/',
29
+ name: 'My Tool',
30
+ summary: '',
31
+ descriptionMd: '',
32
+ ownerSlugs: ['ann@example.com'],
33
+ tags: [],
34
+ }
35
+
36
+ it('refuses approve to a signed-in non-admin', async () => {
37
+ const member = callerAs({ user: { id: 'user-2' }, isAdmin: false })
38
+ await expect(member.approve({ slug: 'my-tool' })).rejects.toMatchObject({
39
+ code: 'FORBIDDEN',
40
+ })
41
+ })
42
+
43
+ it.each([
44
+ ['pins', () => anonymous.pins()],
45
+ ['pin', () => anonymous.pin({ slug: 'my-tool' })],
46
+ ['unpin', () => anonymous.unpin({ slug: 'my-tool' })],
47
+ ['like', () => anonymous.like({ slug: 'my-tool' })],
48
+ ['unlike', () => anonymous.unlike({ slug: 'my-tool' })],
49
+ ['reorderPins', () => anonymous.reorderPins({ slugs: ['my-tool'] })],
50
+ ['preview', () => anonymous.preview({ url: tool.url })],
51
+ ['add', () => anonymous.add(tool)],
52
+ ['edit', () => anonymous.edit({ ...tool, slug: 'my-tool' })],
53
+ ['archive', () => anonymous.archive({ slug: 'my-tool', reason: 'Gone' })],
54
+ ['approve', () => anonymous.approve({ slug: 'my-tool' })],
55
+ ])('answers UNAUTHORIZED to an anonymous %s', async (_, call) => {
56
+ await expect(call()).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
57
+ })
58
+ })