@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,781 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import sharp from 'sharp'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import * as dbClient from '../db/client'
|
|
5
|
+
import type { PrismaClient } from '../generated/prisma/client'
|
|
6
|
+
import { checkTool } from '../modules/communityTools/healthJob'
|
|
7
|
+
import type { CaptureDeps } from '../modules/communityTools/healthJob'
|
|
8
|
+
import * as service from '../modules/communityTools/service'
|
|
9
|
+
import { toView } from '../modules/communityTools/toView'
|
|
10
|
+
import type { User } from 'better-auth/types'
|
|
11
|
+
|
|
12
|
+
/** A deployment that captures nothing: the OSS default. */
|
|
13
|
+
const NO_CAPTURE: CaptureDeps = { captureHosts: [], capturer: null }
|
|
14
|
+
|
|
15
|
+
const mockUser = (partial: {
|
|
16
|
+
id: string
|
|
17
|
+
email: string
|
|
18
|
+
name: string
|
|
19
|
+
}): User => ({
|
|
20
|
+
...partial,
|
|
21
|
+
createdAt: new Date(),
|
|
22
|
+
updatedAt: new Date(),
|
|
23
|
+
emailVerified: false,
|
|
24
|
+
image: null,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('Community tools service', () => {
|
|
28
|
+
const mockDb = {
|
|
29
|
+
dbCommunityTool: {
|
|
30
|
+
findMany: vi.fn(),
|
|
31
|
+
findUnique: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
dbCommunityToolView: {
|
|
34
|
+
upsert: vi.fn(),
|
|
35
|
+
count: vi.fn(),
|
|
36
|
+
findMany: vi.fn(),
|
|
37
|
+
},
|
|
38
|
+
dbCommunityToolPin: {
|
|
39
|
+
findMany: vi.fn(),
|
|
40
|
+
create: vi.fn(),
|
|
41
|
+
delete: vi.fn(),
|
|
42
|
+
deleteMany: vi.fn(),
|
|
43
|
+
update: vi.fn(),
|
|
44
|
+
updateMany: vi.fn(),
|
|
45
|
+
},
|
|
46
|
+
dbCommunityToolOwner: {
|
|
47
|
+
findMany: vi.fn(),
|
|
48
|
+
},
|
|
49
|
+
dbCommunityToolNamedOwner: {
|
|
50
|
+
findMany: vi.fn().mockResolvedValue([]),
|
|
51
|
+
},
|
|
52
|
+
dbCommunityToolTag: {
|
|
53
|
+
findMany: vi.fn(),
|
|
54
|
+
},
|
|
55
|
+
dbPerson: {
|
|
56
|
+
findMany: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
} as unknown as PrismaClient
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue(mockDb)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.clearAllMocks()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('countByApp', () => {
|
|
69
|
+
it('counts live tools per app tag', async () => {
|
|
70
|
+
const groupBy = vi.fn().mockResolvedValue([
|
|
71
|
+
{ value: 'order-service', _count: { _all: 2 } },
|
|
72
|
+
{ value: 'docs', _count: { _all: 1 } },
|
|
73
|
+
])
|
|
74
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue({
|
|
75
|
+
dbCommunityToolTag: { groupBy },
|
|
76
|
+
} as unknown as PrismaClient)
|
|
77
|
+
|
|
78
|
+
expect(await service.countByApp()).toEqual({
|
|
79
|
+
'order-service': 2,
|
|
80
|
+
docs: 1,
|
|
81
|
+
})
|
|
82
|
+
expect(groupBy).toHaveBeenCalledWith({
|
|
83
|
+
by: ['value'],
|
|
84
|
+
where: { kind: 'app', tool: { archivedAt: null } },
|
|
85
|
+
_count: { _all: true },
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('recordView', () => {
|
|
91
|
+
it('counts view twice same day once', async () => {
|
|
92
|
+
const actor = {
|
|
93
|
+
hash: 'test-hash',
|
|
94
|
+
publicId: 'test-public',
|
|
95
|
+
alias: 'Test User',
|
|
96
|
+
kind: 'visitor' as const,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
mockDb.dbCommunityTool.findUnique = vi
|
|
100
|
+
.fn()
|
|
101
|
+
.mockResolvedValue({ id: 'tool-1' })
|
|
102
|
+
mockDb.dbCommunityToolView.upsert = vi.fn().mockResolvedValue({})
|
|
103
|
+
|
|
104
|
+
// Mock count to return different values for each call
|
|
105
|
+
let countCallIndex = 0
|
|
106
|
+
mockDb.dbCommunityToolView.count = vi.fn().mockImplementation(() => {
|
|
107
|
+
const results = [1, 5, 1, 5] // views30d, viewsAll for each recordView call
|
|
108
|
+
return Promise.resolve(results[countCallIndex++])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// First view
|
|
112
|
+
const result1 = await service.recordView(
|
|
113
|
+
'test-slug',
|
|
114
|
+
actor,
|
|
115
|
+
null,
|
|
116
|
+
new Date('2024-01-15'),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Second view same day
|
|
120
|
+
const result2 = await service.recordView(
|
|
121
|
+
'test-slug',
|
|
122
|
+
actor,
|
|
123
|
+
null,
|
|
124
|
+
new Date('2024-01-15'),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
// Both calls should upsert the same row (same day)
|
|
128
|
+
expect(mockDb.dbCommunityToolView.upsert).toHaveBeenCalledTimes(2)
|
|
129
|
+
const calls = vi.mocked(mockDb.dbCommunityToolView.upsert).mock.calls
|
|
130
|
+
const call1 = calls[0]?.[0]
|
|
131
|
+
const call2 = calls[1]?.[0]
|
|
132
|
+
|
|
133
|
+
// Same composite key (toolId, actorHash, day)
|
|
134
|
+
expect(call1?.where).toEqual({
|
|
135
|
+
toolId_actorHash_day: {
|
|
136
|
+
toolId: 'tool-1',
|
|
137
|
+
actorHash: 'test-hash',
|
|
138
|
+
day: new Date('2024-01-15T00:00:00.000Z'),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
expect(call2?.where).toEqual(call1?.where)
|
|
142
|
+
|
|
143
|
+
expect(result1).toEqual({ views30d: 1, viewsAll: 5 })
|
|
144
|
+
expect(result2).toEqual({ views30d: 1, viewsAll: 5 })
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('pins', () => {
|
|
149
|
+
it('returns pins in position order', async () => {
|
|
150
|
+
const userId = 'user-123'
|
|
151
|
+
|
|
152
|
+
// Mock returns data in position order (as if orderBy: { position: 'asc' } was applied)
|
|
153
|
+
mockDb.dbCommunityToolPin.findMany = vi.fn().mockResolvedValue([
|
|
154
|
+
{
|
|
155
|
+
position: 0,
|
|
156
|
+
tool: {
|
|
157
|
+
id: 'tool-a',
|
|
158
|
+
slug: 'tool-a',
|
|
159
|
+
name: 'Tool A',
|
|
160
|
+
url: 'https://a.example.com',
|
|
161
|
+
host: 'a.example.com',
|
|
162
|
+
summary: 'Summary A',
|
|
163
|
+
descriptionMd: 'Description A',
|
|
164
|
+
addedByUserId: 'user-1',
|
|
165
|
+
addedByName: 'User One',
|
|
166
|
+
createdAt: new Date('2024-01-01'),
|
|
167
|
+
reviewedAt: new Date(),
|
|
168
|
+
reviewedByName: null,
|
|
169
|
+
screenshotAssetId: null,
|
|
170
|
+
faviconAssetId: null,
|
|
171
|
+
capturedAt: null,
|
|
172
|
+
siteUpdatedAt: null,
|
|
173
|
+
failingSince: null,
|
|
174
|
+
archivedAt: null,
|
|
175
|
+
archivedBy: null,
|
|
176
|
+
archivedReason: null,
|
|
177
|
+
owners: [],
|
|
178
|
+
namedOwners: [],
|
|
179
|
+
likes: [] as { userId: string; userName: string }[],
|
|
180
|
+
tags: [],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
position: 1,
|
|
185
|
+
tool: {
|
|
186
|
+
id: 'tool-c',
|
|
187
|
+
slug: 'tool-c',
|
|
188
|
+
name: 'Tool C',
|
|
189
|
+
url: 'https://c.example.com',
|
|
190
|
+
host: 'c.example.com',
|
|
191
|
+
summary: 'Summary C',
|
|
192
|
+
descriptionMd: 'Description C',
|
|
193
|
+
addedByUserId: 'user-1',
|
|
194
|
+
addedByName: 'User One',
|
|
195
|
+
createdAt: new Date('2024-01-01'),
|
|
196
|
+
reviewedAt: new Date(),
|
|
197
|
+
reviewedByName: null,
|
|
198
|
+
screenshotAssetId: null,
|
|
199
|
+
faviconAssetId: null,
|
|
200
|
+
capturedAt: null,
|
|
201
|
+
siteUpdatedAt: null,
|
|
202
|
+
failingSince: null,
|
|
203
|
+
archivedAt: null,
|
|
204
|
+
archivedBy: null,
|
|
205
|
+
archivedReason: null,
|
|
206
|
+
owners: [],
|
|
207
|
+
namedOwners: [],
|
|
208
|
+
likes: [] as { userId: string; userName: string }[],
|
|
209
|
+
tags: [],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
position: 2,
|
|
214
|
+
tool: {
|
|
215
|
+
id: 'tool-b',
|
|
216
|
+
slug: 'tool-b',
|
|
217
|
+
name: 'Tool B',
|
|
218
|
+
url: 'https://b.example.com',
|
|
219
|
+
host: 'b.example.com',
|
|
220
|
+
summary: 'Summary B',
|
|
221
|
+
descriptionMd: 'Description B',
|
|
222
|
+
addedByUserId: 'user-1',
|
|
223
|
+
addedByName: 'User One',
|
|
224
|
+
createdAt: new Date('2024-01-01'),
|
|
225
|
+
reviewedAt: new Date(),
|
|
226
|
+
reviewedByName: null,
|
|
227
|
+
screenshotAssetId: null,
|
|
228
|
+
faviconAssetId: null,
|
|
229
|
+
capturedAt: null,
|
|
230
|
+
siteUpdatedAt: null,
|
|
231
|
+
failingSince: null,
|
|
232
|
+
archivedAt: null,
|
|
233
|
+
archivedBy: null,
|
|
234
|
+
archivedReason: null,
|
|
235
|
+
owners: [],
|
|
236
|
+
namedOwners: [],
|
|
237
|
+
likes: [] as { userId: string; userName: string }[],
|
|
238
|
+
tags: [],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
])
|
|
242
|
+
|
|
243
|
+
mockDb.dbPerson.findMany = vi.fn().mockResolvedValue([])
|
|
244
|
+
mockDb.dbCommunityToolView.count = vi.fn().mockResolvedValue(0)
|
|
245
|
+
|
|
246
|
+
const now = new Date('2024-01-15T12:00:00Z')
|
|
247
|
+
const result = await service.listPins(
|
|
248
|
+
userId,
|
|
249
|
+
null,
|
|
250
|
+
false,
|
|
251
|
+
now,
|
|
252
|
+
NO_CAPTURE,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
// Should be sorted by position ascending
|
|
256
|
+
expect(result.map((t) => t.slug)).toEqual(['tool-a', 'tool-c', 'tool-b'])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('reorderPins with partial list keeps unlisted pins', async () => {
|
|
260
|
+
const userId = 'user-123'
|
|
261
|
+
|
|
262
|
+
mockDb.dbCommunityToolPin.findMany = vi.fn().mockResolvedValue([
|
|
263
|
+
{
|
|
264
|
+
userId,
|
|
265
|
+
toolId: 'tool-a',
|
|
266
|
+
position: 0,
|
|
267
|
+
tool: { id: 'tool-a', slug: 'tool-a' },
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
userId,
|
|
271
|
+
toolId: 'tool-b',
|
|
272
|
+
position: 1,
|
|
273
|
+
tool: { id: 'tool-b', slug: 'tool-b' },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
userId,
|
|
277
|
+
toolId: 'tool-c',
|
|
278
|
+
position: 2,
|
|
279
|
+
tool: { id: 'tool-c', slug: 'tool-c' },
|
|
280
|
+
},
|
|
281
|
+
])
|
|
282
|
+
|
|
283
|
+
mockDb.dbCommunityToolPin.update = vi.fn().mockResolvedValue({})
|
|
284
|
+
|
|
285
|
+
// Reorder only tool-c and tool-a (omit tool-b)
|
|
286
|
+
await service.reorderPins(['tool-c', 'tool-a'], userId)
|
|
287
|
+
|
|
288
|
+
// Should call update 3 times (all pins get new positions)
|
|
289
|
+
expect(mockDb.dbCommunityToolPin.update).toHaveBeenCalledTimes(3)
|
|
290
|
+
|
|
291
|
+
// tool-c at position 0, tool-a at position 1, tool-b at position 2
|
|
292
|
+
const calls = vi.mocked(mockDb.dbCommunityToolPin.update).mock.calls
|
|
293
|
+
expect(calls[0]?.[0]).toMatchObject({
|
|
294
|
+
where: { userId_toolId: { userId, toolId: 'tool-c' } },
|
|
295
|
+
data: { position: 0 },
|
|
296
|
+
})
|
|
297
|
+
expect(calls[1]?.[0]).toMatchObject({
|
|
298
|
+
where: { userId_toolId: { userId, toolId: 'tool-a' } },
|
|
299
|
+
data: { position: 1 },
|
|
300
|
+
})
|
|
301
|
+
expect(calls[2]?.[0]).toMatchObject({
|
|
302
|
+
where: { userId_toolId: { userId, toolId: 'tool-b' } },
|
|
303
|
+
data: { position: 2 },
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
describe('canEdit', () => {
|
|
309
|
+
it('returns true for owner email', async () => {
|
|
310
|
+
const user = mockUser({
|
|
311
|
+
id: 'user-1',
|
|
312
|
+
email: 'owner@example.com',
|
|
313
|
+
name: 'Owner',
|
|
314
|
+
})
|
|
315
|
+
const tool = {
|
|
316
|
+
id: 'tool-1',
|
|
317
|
+
slug: 'test',
|
|
318
|
+
addedByUserId: 'other-user',
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
vi.spyOn(mockDb.dbCommunityToolOwner, 'findMany').mockResolvedValue([
|
|
322
|
+
{ toolId: 'tool-1', personSlug: 'owner@example.com' },
|
|
323
|
+
])
|
|
324
|
+
|
|
325
|
+
const canEdit = await service.computeCanEdit(tool, user, false)
|
|
326
|
+
expect(canEdit).toBe(true)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('returns true for adder', async () => {
|
|
330
|
+
const user = mockUser({
|
|
331
|
+
id: 'user-1',
|
|
332
|
+
email: 'user@example.com',
|
|
333
|
+
name: 'User',
|
|
334
|
+
})
|
|
335
|
+
const tool = {
|
|
336
|
+
id: 'tool-1',
|
|
337
|
+
slug: 'test',
|
|
338
|
+
addedByUserId: 'user-1',
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
vi.spyOn(mockDb.dbCommunityToolOwner, 'findMany').mockResolvedValue([])
|
|
342
|
+
|
|
343
|
+
const canEdit = await service.computeCanEdit(tool, user, false)
|
|
344
|
+
expect(canEdit).toBe(true)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('returns false for stranger', async () => {
|
|
348
|
+
const user = mockUser({
|
|
349
|
+
id: 'user-1',
|
|
350
|
+
email: 'stranger@example.com',
|
|
351
|
+
name: 'Stranger',
|
|
352
|
+
})
|
|
353
|
+
const tool = {
|
|
354
|
+
id: 'tool-1',
|
|
355
|
+
slug: 'test',
|
|
356
|
+
addedByUserId: 'other-user',
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
vi.spyOn(mockDb.dbCommunityToolOwner, 'findMany').mockResolvedValue([
|
|
360
|
+
{ toolId: 'tool-1', personSlug: 'owner@example.com' },
|
|
361
|
+
])
|
|
362
|
+
|
|
363
|
+
const canEdit = await service.computeCanEdit(tool, user, false)
|
|
364
|
+
expect(canEdit).toBe(false)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('returns true for admin', async () => {
|
|
368
|
+
const user = mockUser({
|
|
369
|
+
id: 'user-1',
|
|
370
|
+
email: 'stranger@example.com',
|
|
371
|
+
name: 'Stranger',
|
|
372
|
+
})
|
|
373
|
+
const tool = {
|
|
374
|
+
id: 'tool-1',
|
|
375
|
+
slug: 'test',
|
|
376
|
+
addedByUserId: 'other-user',
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
vi.spyOn(mockDb.dbCommunityToolOwner, 'findMany').mockResolvedValue([])
|
|
380
|
+
|
|
381
|
+
const canEdit = await service.computeCanEdit(tool, user, true)
|
|
382
|
+
expect(canEdit).toBe(true)
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
describe('list', () => {
|
|
387
|
+
it('maps lifecycle via lifecycleOf', async () => {
|
|
388
|
+
const now = new Date('2024-01-15T12:00:00Z')
|
|
389
|
+
|
|
390
|
+
mockDb.dbCommunityTool.findMany = vi.fn().mockResolvedValue([
|
|
391
|
+
{
|
|
392
|
+
id: 'tool-1',
|
|
393
|
+
slug: 'active-tool',
|
|
394
|
+
name: 'Active Tool',
|
|
395
|
+
url: 'https://active.example.com',
|
|
396
|
+
host: 'active.example.com',
|
|
397
|
+
summary: 'Summary',
|
|
398
|
+
descriptionMd: 'Description',
|
|
399
|
+
addedByUserId: 'user-1',
|
|
400
|
+
addedByName: 'User One',
|
|
401
|
+
createdAt: new Date('2024-01-01'),
|
|
402
|
+
reviewed: true,
|
|
403
|
+
screenshotId: null,
|
|
404
|
+
faviconId: null,
|
|
405
|
+
capturedAt: null,
|
|
406
|
+
siteUpdatedAt: null,
|
|
407
|
+
failingSince: null,
|
|
408
|
+
archivedAt: null,
|
|
409
|
+
archivedBy: null,
|
|
410
|
+
archivedReason: null,
|
|
411
|
+
owners: [],
|
|
412
|
+
namedOwners: [],
|
|
413
|
+
likes: [] as { userId: string; userName: string }[],
|
|
414
|
+
tags: [],
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: 'tool-2',
|
|
418
|
+
slug: 'failing-tool',
|
|
419
|
+
name: 'Failing Tool',
|
|
420
|
+
url: 'https://failing.example.com',
|
|
421
|
+
host: 'failing.example.com',
|
|
422
|
+
summary: 'Summary',
|
|
423
|
+
descriptionMd: 'Description',
|
|
424
|
+
addedByUserId: 'user-1',
|
|
425
|
+
addedByName: 'User One',
|
|
426
|
+
createdAt: new Date('2024-01-01'),
|
|
427
|
+
reviewed: true,
|
|
428
|
+
screenshotId: null,
|
|
429
|
+
faviconId: null,
|
|
430
|
+
capturedAt: null,
|
|
431
|
+
siteUpdatedAt: null,
|
|
432
|
+
failingSince: new Date('2024-01-10T00:00:00Z'),
|
|
433
|
+
archivedAt: null,
|
|
434
|
+
archivedBy: null,
|
|
435
|
+
archivedReason: null,
|
|
436
|
+
owners: [],
|
|
437
|
+
namedOwners: [],
|
|
438
|
+
likes: [] as { userId: string; userName: string }[],
|
|
439
|
+
tags: [],
|
|
440
|
+
},
|
|
441
|
+
])
|
|
442
|
+
|
|
443
|
+
mockDb.dbPerson.findMany = vi.fn().mockResolvedValue([])
|
|
444
|
+
mockDb.dbCommunityToolView.count = vi.fn().mockResolvedValue(0)
|
|
445
|
+
|
|
446
|
+
const result = await service.listTools(null, false, now, NO_CAPTURE)
|
|
447
|
+
|
|
448
|
+
// Active tool should have state: 'active'
|
|
449
|
+
expect(result[0]?.lifecycle).toEqual({ state: 'active' })
|
|
450
|
+
|
|
451
|
+
// Failing tool should have state: 'not-working' with days calculated
|
|
452
|
+
// Whole days of the streak: 5.5 days is 5
|
|
453
|
+
expect(result[1]?.lifecycle.state).toBe('not-working')
|
|
454
|
+
expect((result[1]?.lifecycle as any).days).toBe(5)
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
describe('capturePending', () => {
|
|
459
|
+
const capturer = { capture: vi.fn() }
|
|
460
|
+
const CAPTURES: CaptureDeps = {
|
|
461
|
+
captureHosts: ['example.com'],
|
|
462
|
+
capturer,
|
|
463
|
+
}
|
|
464
|
+
const row = (over: Record<string, unknown> = {}) =>
|
|
465
|
+
({
|
|
466
|
+
id: 'tool-1',
|
|
467
|
+
slug: 'new-tool',
|
|
468
|
+
name: 'New Tool',
|
|
469
|
+
url: 'https://new-tool.pages.example.com/',
|
|
470
|
+
host: 'new-tool.pages.example.com',
|
|
471
|
+
summary: '',
|
|
472
|
+
descriptionMd: '',
|
|
473
|
+
addedByUserId: 'user-1',
|
|
474
|
+
addedByName: 'User One',
|
|
475
|
+
createdAt: new Date('2024-01-01'),
|
|
476
|
+
updatedAt: new Date('2024-01-01'),
|
|
477
|
+
reviewedAt: null,
|
|
478
|
+
reviewedByName: null,
|
|
479
|
+
lastCheckAt: null,
|
|
480
|
+
failingSince: null,
|
|
481
|
+
archivedAt: null,
|
|
482
|
+
archivedReason: null,
|
|
483
|
+
archivedBy: null,
|
|
484
|
+
screenshotAssetId: null,
|
|
485
|
+
faviconAssetId: null,
|
|
486
|
+
capturedAt: null,
|
|
487
|
+
siteUpdatedAt: null,
|
|
488
|
+
owners: [],
|
|
489
|
+
namedOwners: [],
|
|
490
|
+
likes: [] as { userId: string; userName: string }[],
|
|
491
|
+
tags: [],
|
|
492
|
+
...over,
|
|
493
|
+
}) as Parameters<typeof toView>[0]
|
|
494
|
+
const pending = async (
|
|
495
|
+
over: Record<string, unknown>,
|
|
496
|
+
capture: CaptureDeps = CAPTURES,
|
|
497
|
+
) =>
|
|
498
|
+
(
|
|
499
|
+
await toView(
|
|
500
|
+
row(over),
|
|
501
|
+
new Map(),
|
|
502
|
+
{ views30d: 0, viewsAll: 0 },
|
|
503
|
+
null,
|
|
504
|
+
false,
|
|
505
|
+
new Date('2024-01-15T12:00:00Z'),
|
|
506
|
+
capture,
|
|
507
|
+
)
|
|
508
|
+
).capturePending
|
|
509
|
+
|
|
510
|
+
it('counts likes, says whether I liked it, and names 8 others, newest first', async () => {
|
|
511
|
+
const likes = Array.from({ length: 10 }, (_, i) => ({
|
|
512
|
+
userId: `user-${i}`,
|
|
513
|
+
userName: `Person ${i}`,
|
|
514
|
+
}))
|
|
515
|
+
const view = await toView(
|
|
516
|
+
row({ likes }),
|
|
517
|
+
new Map(),
|
|
518
|
+
{ views30d: 0, viewsAll: 0 },
|
|
519
|
+
mockUser({ id: 'user-3', email: 'p3@example.com', name: 'Person 3' }),
|
|
520
|
+
false,
|
|
521
|
+
new Date('2024-01-15T12:00:00Z'),
|
|
522
|
+
CAPTURES,
|
|
523
|
+
)
|
|
524
|
+
expect(view.likes).toBe(10)
|
|
525
|
+
expect(view.likedByMe).toBe(true)
|
|
526
|
+
// My own like is likedByMe, never one of the names.
|
|
527
|
+
expect(view.likedBy).toEqual(
|
|
528
|
+
likes
|
|
529
|
+
.filter((l) => l.userId !== 'user-3')
|
|
530
|
+
.slice(0, 8)
|
|
531
|
+
.map((l) => l.userName),
|
|
532
|
+
)
|
|
533
|
+
const anonymous = await toView(
|
|
534
|
+
row({ likes }),
|
|
535
|
+
new Map(),
|
|
536
|
+
{ views30d: 0, viewsAll: 0 },
|
|
537
|
+
null,
|
|
538
|
+
false,
|
|
539
|
+
new Date('2024-01-15T12:00:00Z'),
|
|
540
|
+
CAPTURES,
|
|
541
|
+
)
|
|
542
|
+
expect(anonymous.likedByMe).toBe(false)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('lists an owner outside the directory by name, with no slug', async () => {
|
|
546
|
+
const view = await toView(
|
|
547
|
+
row({
|
|
548
|
+
owners: [{ toolId: 'tool-1', personSlug: 'dana@example.com' }],
|
|
549
|
+
namedOwners: [{ toolId: 'tool-1', name: 'Kim Outside' }],
|
|
550
|
+
}),
|
|
551
|
+
new Map(),
|
|
552
|
+
{ views30d: 0, viewsAll: 0 },
|
|
553
|
+
null,
|
|
554
|
+
false,
|
|
555
|
+
new Date('2024-01-15T12:00:00Z'),
|
|
556
|
+
CAPTURES,
|
|
557
|
+
)
|
|
558
|
+
expect(view.owners).toEqual([
|
|
559
|
+
{ slug: 'dana@example.com', name: 'dana@example.com' },
|
|
560
|
+
{ slug: null, name: 'Kim Outside' },
|
|
561
|
+
])
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('is pending for a new tool on an allowed host with a capturer', async () => {
|
|
565
|
+
expect(await pending({})).toBe(true)
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('is not pending once there is a screenshot', async () => {
|
|
569
|
+
expect(await pending({ screenshotAssetId: 'shot-1' })).toBe(false)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('is not pending once the first visit ran, even if it failed', async () => {
|
|
573
|
+
expect(await pending({ lastCheckAt: new Date('2024-01-15') })).toBe(false)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('is not pending after a capture that threw', async () => {
|
|
577
|
+
const db = {
|
|
578
|
+
dbCommunityTool: { update: vi.fn().mockResolvedValue({}) },
|
|
579
|
+
}
|
|
580
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue(
|
|
581
|
+
db as unknown as PrismaClient,
|
|
582
|
+
)
|
|
583
|
+
const now = new Date('2024-01-15T12:00:00Z')
|
|
584
|
+
const launchFails = {
|
|
585
|
+
capture: vi.fn().mockRejectedValue(new Error('launch failed')),
|
|
586
|
+
}
|
|
587
|
+
await expect(
|
|
588
|
+
checkTool(
|
|
589
|
+
{
|
|
590
|
+
id: 'tool-1',
|
|
591
|
+
slug: 'new-tool',
|
|
592
|
+
url: 'https://new-tool.pages.example.com/',
|
|
593
|
+
failingSince: null,
|
|
594
|
+
archivedAt: null,
|
|
595
|
+
archivedBy: null,
|
|
596
|
+
},
|
|
597
|
+
{ ...CAPTURES, capturer: launchFails },
|
|
598
|
+
now,
|
|
599
|
+
),
|
|
600
|
+
).rejects.toThrow('launch failed')
|
|
601
|
+
// Whatever the throw wrote is what the next read sees.
|
|
602
|
+
const written = db.dbCommunityTool.update.mock.calls[0]?.[0].data
|
|
603
|
+
expect(written).toEqual({ lastCheckAt: now })
|
|
604
|
+
expect(await pending(written)).toBe(false)
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
describe('when the site last changed', () => {
|
|
608
|
+
const visit = async (lastModified: Date | null) => {
|
|
609
|
+
const db = {
|
|
610
|
+
dbCommunityTool: { update: vi.fn().mockResolvedValue({}) },
|
|
611
|
+
dbAsset: {
|
|
612
|
+
findUnique: vi.fn().mockResolvedValue(null),
|
|
613
|
+
create: vi.fn().mockResolvedValue({ id: 'asset-1' }),
|
|
614
|
+
},
|
|
615
|
+
}
|
|
616
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue(
|
|
617
|
+
db as unknown as PrismaClient,
|
|
618
|
+
)
|
|
619
|
+
const png = await sharp({
|
|
620
|
+
create: { width: 4, height: 4, channels: 3, background: '#fff' },
|
|
621
|
+
})
|
|
622
|
+
.png()
|
|
623
|
+
.toBuffer()
|
|
624
|
+
const capture = vi.fn().mockResolvedValue({
|
|
625
|
+
ok: true,
|
|
626
|
+
png,
|
|
627
|
+
title: null,
|
|
628
|
+
favicon: null,
|
|
629
|
+
lastModified,
|
|
630
|
+
})
|
|
631
|
+
await checkTool(
|
|
632
|
+
{
|
|
633
|
+
id: 'tool-1',
|
|
634
|
+
slug: 'new-tool',
|
|
635
|
+
url: 'https://new-tool.pages.example.com/',
|
|
636
|
+
failingSince: null,
|
|
637
|
+
archivedAt: null,
|
|
638
|
+
archivedBy: null,
|
|
639
|
+
},
|
|
640
|
+
{ ...CAPTURES, capturer: { capture } },
|
|
641
|
+
new Date('2024-01-15T12:00:00Z'),
|
|
642
|
+
)
|
|
643
|
+
return db.dbCommunityTool.update.mock.calls[0]?.[0].data as Record<
|
|
644
|
+
string,
|
|
645
|
+
unknown
|
|
646
|
+
>
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
it('is stored from the page Last-Modified', async () => {
|
|
650
|
+
const changed = new Date('2024-01-10T08:00:00Z')
|
|
651
|
+
expect((await visit(changed))['siteUpdatedAt']).toEqual(changed)
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('is left as it was when the page sends none', async () => {
|
|
655
|
+
expect(await visit(null)).not.toHaveProperty('siteUpdatedAt')
|
|
656
|
+
})
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('is not pending without a capturer', async () => {
|
|
660
|
+
expect(await pending({}, { ...CAPTURES, capturer: null })).toBe(false)
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it('is not pending for a host outside the capture suffixes', async () => {
|
|
664
|
+
expect(
|
|
665
|
+
await pending({
|
|
666
|
+
url: 'https://tool.other.test/',
|
|
667
|
+
host: 'tool.other.test',
|
|
668
|
+
}),
|
|
669
|
+
).toBe(false)
|
|
670
|
+
})
|
|
671
|
+
})
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
describe('pin and unpin', () => {
|
|
675
|
+
const db = {
|
|
676
|
+
dbCommunityTool: { findUnique: vi.fn() },
|
|
677
|
+
dbCommunityToolPin: {
|
|
678
|
+
findFirst: vi.fn(),
|
|
679
|
+
upsert: vi.fn(),
|
|
680
|
+
deleteMany: vi.fn(),
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
beforeEach(() => {
|
|
685
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue(
|
|
686
|
+
db as unknown as PrismaClient,
|
|
687
|
+
)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
afterEach(() => {
|
|
691
|
+
vi.restoreAllMocks()
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('answers NOT_FOUND for an unknown slug', async () => {
|
|
695
|
+
db.dbCommunityTool.findUnique.mockResolvedValue(null)
|
|
696
|
+
|
|
697
|
+
await expect(service.pinTool('nope', 'user-1')).rejects.toMatchObject({
|
|
698
|
+
code: 'NOT_FOUND',
|
|
699
|
+
})
|
|
700
|
+
await expect(service.unpinTool('nope', 'user-1')).rejects.toMatchObject({
|
|
701
|
+
code: 'NOT_FOUND',
|
|
702
|
+
})
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('leaves a pinned tool where it is when pinned again', async () => {
|
|
706
|
+
db.dbCommunityTool.findUnique.mockResolvedValue({ id: 'tool-a' })
|
|
707
|
+
db.dbCommunityToolPin.findFirst.mockResolvedValue({ position: 4 })
|
|
708
|
+
|
|
709
|
+
await service.pinTool('tool-a', 'user-1')
|
|
710
|
+
|
|
711
|
+
expect(db.dbCommunityToolPin.upsert).toHaveBeenCalledWith({
|
|
712
|
+
where: { userId_toolId: { userId: 'user-1', toolId: 'tool-a' } },
|
|
713
|
+
create: { userId: 'user-1', toolId: 'tool-a', position: 5 },
|
|
714
|
+
update: {},
|
|
715
|
+
})
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
describe('like and unlike', () => {
|
|
720
|
+
const db = {
|
|
721
|
+
dbCommunityTool: { findUnique: vi.fn() },
|
|
722
|
+
dbCommunityToolLike: { upsert: vi.fn(), deleteMany: vi.fn() },
|
|
723
|
+
}
|
|
724
|
+
const ann = { id: 'user-1', name: 'Ann Owner' }
|
|
725
|
+
|
|
726
|
+
beforeEach(() => {
|
|
727
|
+
vi.spyOn(dbClient, 'getDbClient').mockReturnValue(
|
|
728
|
+
db as unknown as PrismaClient,
|
|
729
|
+
)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
afterEach(() => {
|
|
733
|
+
vi.restoreAllMocks()
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it('likes once however often it is asked, keeping the name for Liked by', async () => {
|
|
737
|
+
db.dbCommunityTool.findUnique.mockResolvedValue({ id: 'tool-a' })
|
|
738
|
+
|
|
739
|
+
await service.likeTool('tool-a', ann)
|
|
740
|
+
await service.likeTool('tool-a', ann)
|
|
741
|
+
|
|
742
|
+
expect(db.dbCommunityToolLike.upsert).toHaveBeenCalledTimes(2)
|
|
743
|
+
expect(db.dbCommunityToolLike.upsert).toHaveBeenLastCalledWith({
|
|
744
|
+
where: { toolId_userId: { toolId: 'tool-a', userId: 'user-1' } },
|
|
745
|
+
create: { toolId: 'tool-a', userId: 'user-1', userName: 'Ann Owner' },
|
|
746
|
+
update: {},
|
|
747
|
+
})
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it('counts a like that lost a race with the same like as liked', async () => {
|
|
751
|
+
db.dbCommunityTool.findUnique.mockResolvedValue({ id: 'tool-a' })
|
|
752
|
+
db.dbCommunityToolLike.upsert.mockRejectedValueOnce(
|
|
753
|
+
Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }),
|
|
754
|
+
)
|
|
755
|
+
await expect(service.likeTool('tool-a', ann)).resolves.toBeUndefined()
|
|
756
|
+
|
|
757
|
+
db.dbCommunityToolLike.upsert.mockRejectedValueOnce(new Error('db down'))
|
|
758
|
+
await expect(service.likeTool('tool-a', ann)).rejects.toThrow('db down')
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it('unlikes only my own like, and an unliked tool stays unliked', async () => {
|
|
762
|
+
db.dbCommunityTool.findUnique.mockResolvedValue({ id: 'tool-a' })
|
|
763
|
+
|
|
764
|
+
await service.unlikeTool('tool-a', 'user-1')
|
|
765
|
+
|
|
766
|
+
expect(db.dbCommunityToolLike.deleteMany).toHaveBeenCalledWith({
|
|
767
|
+
where: { toolId: 'tool-a', userId: 'user-1' },
|
|
768
|
+
})
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('answers NOT_FOUND for an unknown slug', async () => {
|
|
772
|
+
db.dbCommunityTool.findUnique.mockResolvedValue(null)
|
|
773
|
+
|
|
774
|
+
await expect(service.likeTool('nope', ann)).rejects.toMatchObject({
|
|
775
|
+
code: 'NOT_FOUND',
|
|
776
|
+
})
|
|
777
|
+
await expect(service.unlikeTool('nope', 'user-1')).rejects.toMatchObject({
|
|
778
|
+
code: 'NOT_FOUND',
|
|
779
|
+
})
|
|
780
|
+
})
|
|
781
|
+
})
|