@shipfox/api-projects 11.0.0 → 12.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +53 -0
- package/README.md +83 -0
- package/dist/core/entities/project.d.ts +4 -0
- package/dist/core/entities/project.d.ts.map +1 -1
- package/dist/core/entities/project.js.map +1 -1
- package/dist/core/errors.d.ts +4 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/projects.d.ts +8 -0
- package/dist/core/projects.d.ts.map +1 -1
- package/dist/core/projects.js +62 -19
- package/dist/core/projects.js.map +1 -1
- package/dist/db/db.d.ts +144 -6
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js.map +1 -1
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/projects.d.ts +53 -0
- package/dist/db/projects.d.ts.map +1 -1
- package/dist/db/projects.js +116 -18
- package/dist/db/projects.js.map +1 -1
- package/dist/db/schema/integration-event-dedup.d.ts +1 -1
- package/dist/db/schema/outbox.d.ts +1 -1
- package/dist/db/schema/projects.d.ts +69 -1
- package/dist/db/schema/projects.d.ts.map +1 -1
- package/dist/db/schema/projects.js +12 -1
- package/dist/db/schema/projects.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/project.d.ts +2 -1
- package/dist/presentation/dto/project.d.ts.map +1 -1
- package/dist/presentation/dto/project.js +1 -0
- package/dist/presentation/dto/project.js.map +1 -1
- package/dist/presentation/e2eRoutes/create-project.d.ts.map +1 -1
- package/dist/presentation/e2eRoutes/create-project.js +34 -8
- package/dist/presentation/e2eRoutes/create-project.js.map +1 -1
- package/dist/presentation/inter-module.d.ts +4 -1
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +63 -3
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/create-project.d.ts.map +1 -1
- package/dist/presentation/routes/create-project.js +8 -2
- package/dist/presentation/routes/create-project.js.map +1 -1
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +3 -1
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/update-project.d.ts +2 -0
- package/dist/presentation/routes/update-project.d.ts.map +1 -0
- package/dist/presentation/routes/update-project.js +53 -0
- package/dist/presentation/routes/update-project.js.map +1 -0
- package/dist/presentation/subscribers/index.d.ts +1 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -1
- package/dist/presentation/subscribers/index.js +1 -0
- package/dist/presentation/subscribers/index.js.map +1 -1
- package/dist/presentation/subscribers/on-source-repository-updated.d.ts +4 -0
- package/dist/presentation/subscribers/on-source-repository-updated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-source-repository-updated.js +39 -0
- package/dist/presentation/subscribers/on-source-repository-updated.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0000_initial.sql +6 -0
- package/drizzle/meta/0000_snapshot.json +78 -0
- package/package.json +13 -12
- package/src/core/entities/project.ts +4 -0
- package/src/core/errors.ts +7 -0
- package/src/core/index.ts +3 -2
- package/src/core/projects.test.ts +10 -0
- package/src/core/projects.ts +90 -35
- package/src/db/db.ts +3 -0
- package/src/db/index.ts +6 -0
- package/src/db/projects.test.ts +44 -1
- package/src/db/projects.ts +242 -33
- package/src/db/schema/projects.ts +16 -0
- package/src/index.ts +9 -3
- package/src/presentation/dto/project.ts +1 -0
- package/src/presentation/e2eRoutes/create-project.test.ts +23 -0
- package/src/presentation/e2eRoutes/create-project.ts +39 -10
- package/src/presentation/inter-module.test.ts +349 -0
- package/src/presentation/inter-module.ts +90 -4
- package/src/presentation/routes/create-project.ts +10 -2
- package/src/presentation/routes/index.ts +7 -1
- package/src/presentation/routes/projects.test.ts +300 -4
- package/src/presentation/routes/update-project.ts +46 -0
- package/src/presentation/subscribers/index.ts +1 -0
- package/src/presentation/subscribers/on-source-commit-pushed.test.ts +2 -0
- package/src/presentation/subscribers/on-source-repository-updated.test.ts +108 -0
- package/src/presentation/subscribers/on-source-repository-updated.ts +50 -0
- package/test/factories/project.ts +6 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -15,9 +15,12 @@ import {
|
|
|
15
15
|
import {createInterModuleKnownError} from '@shipfox/inter-module';
|
|
16
16
|
import type {AuthMethod} from '@shipfox/node-fastify';
|
|
17
17
|
import {closeApp, createApp} from '@shipfox/node-fastify';
|
|
18
|
+
import {sql} from 'drizzle-orm';
|
|
18
19
|
import type {FastifyInstance, FastifyRequest} from 'fastify';
|
|
19
20
|
import type {Project} from '#core/entities/project.js';
|
|
21
|
+
import {db} from '#db/db.js';
|
|
20
22
|
import {createProject} from '#db/projects.js';
|
|
23
|
+
import {projectsOutbox} from '#db/schema/outbox.js';
|
|
21
24
|
import {createProjectRoutes} from './index.js';
|
|
22
25
|
|
|
23
26
|
let authenticatedMemberships: UserContextMembership[] = [];
|
|
@@ -95,6 +98,7 @@ describe('project routes', () => {
|
|
|
95
98
|
payload: {
|
|
96
99
|
workspace_id: workspaceId,
|
|
97
100
|
name: ' Platform ',
|
|
101
|
+
slug: 'platform',
|
|
98
102
|
source: {
|
|
99
103
|
connection_id: sourceConnectionId,
|
|
100
104
|
external_repository_id: 'gitea:gitea-owner/platform',
|
|
@@ -104,12 +108,290 @@ describe('project routes', () => {
|
|
|
104
108
|
|
|
105
109
|
expect(res.statusCode).toBe(201);
|
|
106
110
|
expect(res.json().name).toBe('Platform');
|
|
111
|
+
expect(res.json().slug).toBe('platform');
|
|
107
112
|
expect(res.json().source).toEqual({
|
|
108
113
|
connection_id: sourceConnectionId,
|
|
109
114
|
external_repository_id: 'gitea:gitea-owner/platform',
|
|
110
115
|
});
|
|
111
116
|
});
|
|
112
117
|
|
|
118
|
+
test('returns a distinct conflict when a slug is taken in the workspace', async () => {
|
|
119
|
+
await app.inject({
|
|
120
|
+
method: 'POST',
|
|
121
|
+
url: '/projects',
|
|
122
|
+
headers: {authorization: 'Bearer user'},
|
|
123
|
+
payload: {
|
|
124
|
+
workspace_id: workspaceId,
|
|
125
|
+
name: 'Platform',
|
|
126
|
+
slug: 'platform',
|
|
127
|
+
source: {
|
|
128
|
+
connection_id: sourceConnectionId,
|
|
129
|
+
external_repository_id: 'gitea:gitea-owner/platform',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
vi.mocked(integrations.resolveSourceRepository).mockResolvedValueOnce({
|
|
134
|
+
connection: {
|
|
135
|
+
id: crypto.randomUUID(),
|
|
136
|
+
provider: 'gitea',
|
|
137
|
+
slug: 'gitea_owner',
|
|
138
|
+
},
|
|
139
|
+
repository: {
|
|
140
|
+
externalRepositoryId: 'gitea:gitea-owner/other',
|
|
141
|
+
owner: 'gitea-owner',
|
|
142
|
+
name: 'other',
|
|
143
|
+
fullName: 'gitea-owner/other',
|
|
144
|
+
defaultBranch: 'main',
|
|
145
|
+
visibility: 'private',
|
|
146
|
+
cloneUrl: 'https://gitea.local/gitea-owner/other.git',
|
|
147
|
+
htmlUrl: 'https://gitea.local/gitea-owner/other',
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const res = await app.inject({
|
|
152
|
+
method: 'POST',
|
|
153
|
+
url: '/projects',
|
|
154
|
+
headers: {authorization: 'Bearer user'},
|
|
155
|
+
payload: {
|
|
156
|
+
workspace_id: workspaceId,
|
|
157
|
+
name: 'Other',
|
|
158
|
+
slug: 'platform',
|
|
159
|
+
source: {
|
|
160
|
+
connection_id: crypto.randomUUID(),
|
|
161
|
+
external_repository_id: 'gitea:gitea-owner/other',
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(res.statusCode).toBe(409);
|
|
167
|
+
expect(res.json().code).toBe('slug-conflict');
|
|
168
|
+
expect(res.json().code).not.toBe('project-already-exists');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('allows the same slug in different workspaces', async () => {
|
|
172
|
+
const secondWorkspaceId = crypto.randomUUID();
|
|
173
|
+
authenticatedMemberships = [
|
|
174
|
+
{workspaceId, role: 'admin', workspaceStatus: 'active'},
|
|
175
|
+
{workspaceId: secondWorkspaceId, role: 'admin', workspaceStatus: 'active'},
|
|
176
|
+
];
|
|
177
|
+
await app.inject({
|
|
178
|
+
method: 'POST',
|
|
179
|
+
url: '/projects',
|
|
180
|
+
headers: {authorization: 'Bearer user'},
|
|
181
|
+
payload: {
|
|
182
|
+
workspace_id: workspaceId,
|
|
183
|
+
name: 'Platform',
|
|
184
|
+
slug: 'platform',
|
|
185
|
+
source: {
|
|
186
|
+
connection_id: sourceConnectionId,
|
|
187
|
+
external_repository_id: 'gitea:gitea-owner/platform',
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
vi.mocked(integrations.resolveSourceRepository).mockResolvedValueOnce({
|
|
192
|
+
connection: {
|
|
193
|
+
id: crypto.randomUUID(),
|
|
194
|
+
provider: 'gitea',
|
|
195
|
+
slug: 'gitea_owner',
|
|
196
|
+
},
|
|
197
|
+
repository: {
|
|
198
|
+
externalRepositoryId: 'gitea:gitea-owner/other-workspace',
|
|
199
|
+
owner: 'gitea-owner',
|
|
200
|
+
name: 'other-workspace',
|
|
201
|
+
fullName: 'gitea-owner/other-workspace',
|
|
202
|
+
defaultBranch: 'main',
|
|
203
|
+
visibility: 'private',
|
|
204
|
+
cloneUrl: 'https://gitea.local/gitea-owner/other-workspace.git',
|
|
205
|
+
htmlUrl: 'https://gitea.local/gitea-owner/other-workspace',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const res = await app.inject({
|
|
210
|
+
method: 'POST',
|
|
211
|
+
url: '/projects',
|
|
212
|
+
headers: {authorization: 'Bearer user'},
|
|
213
|
+
payload: {
|
|
214
|
+
workspace_id: secondWorkspaceId,
|
|
215
|
+
name: 'Platform',
|
|
216
|
+
slug: 'platform',
|
|
217
|
+
source: {
|
|
218
|
+
connection_id: crypto.randomUUID(),
|
|
219
|
+
external_repository_id: 'gitea:gitea-owner/other-workspace',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(res.statusCode).toBe(201);
|
|
225
|
+
expect(res.json().slug).toBe('platform');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('accepts a project slug named new', async () => {
|
|
229
|
+
const res = await app.inject({
|
|
230
|
+
method: 'POST',
|
|
231
|
+
url: '/projects',
|
|
232
|
+
headers: {authorization: 'Bearer user'},
|
|
233
|
+
payload: {
|
|
234
|
+
workspace_id: workspaceId,
|
|
235
|
+
name: 'New Project',
|
|
236
|
+
slug: 'new',
|
|
237
|
+
source: {
|
|
238
|
+
connection_id: sourceConnectionId,
|
|
239
|
+
external_repository_id: 'gitea:gitea-owner/platform',
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(res.statusCode).toBe(201);
|
|
245
|
+
expect(res.json().slug).toBe('new');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('renames a project, frees the old slug, and writes an update event', async () => {
|
|
249
|
+
const createRes = await app.inject({
|
|
250
|
+
method: 'POST',
|
|
251
|
+
url: '/projects',
|
|
252
|
+
headers: {authorization: 'Bearer user'},
|
|
253
|
+
payload: {
|
|
254
|
+
workspace_id: workspaceId,
|
|
255
|
+
name: 'Platform',
|
|
256
|
+
slug: 'platform',
|
|
257
|
+
source: {
|
|
258
|
+
connection_id: sourceConnectionId,
|
|
259
|
+
external_repository_id: 'gitea:gitea-owner/platform',
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const patchRes = await app.inject({
|
|
265
|
+
method: 'PATCH',
|
|
266
|
+
url: `/projects/${createRes.json().id}`,
|
|
267
|
+
headers: {authorization: 'Bearer user'},
|
|
268
|
+
payload: {name: 'Renamed Platform', slug: 'renamed-platform'},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(patchRes.statusCode).toBe(200);
|
|
272
|
+
expect(patchRes.json()).toMatchObject({
|
|
273
|
+
name: 'Renamed Platform',
|
|
274
|
+
slug: 'renamed-platform',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const events = await db()
|
|
278
|
+
.select()
|
|
279
|
+
.from(projectsOutbox)
|
|
280
|
+
.where(sql`${projectsOutbox.payload}->>'projectId' = ${createRes.json().id}`);
|
|
281
|
+
expect(events).toEqual(
|
|
282
|
+
expect.arrayContaining([
|
|
283
|
+
expect.objectContaining({
|
|
284
|
+
eventType: 'projects.project.updated',
|
|
285
|
+
payload: expect.objectContaining({
|
|
286
|
+
name: 'Renamed Platform',
|
|
287
|
+
slug: 'renamed-platform',
|
|
288
|
+
}),
|
|
289
|
+
}),
|
|
290
|
+
]),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
vi.mocked(integrations.resolveSourceRepository).mockResolvedValueOnce({
|
|
294
|
+
connection: {
|
|
295
|
+
id: crypto.randomUUID(),
|
|
296
|
+
provider: 'gitea',
|
|
297
|
+
slug: 'gitea_owner',
|
|
298
|
+
},
|
|
299
|
+
repository: {
|
|
300
|
+
externalRepositoryId: 'gitea:gitea-owner/other',
|
|
301
|
+
owner: 'gitea-owner',
|
|
302
|
+
name: 'other',
|
|
303
|
+
fullName: 'gitea-owner/other',
|
|
304
|
+
defaultBranch: 'main',
|
|
305
|
+
visibility: 'private',
|
|
306
|
+
cloneUrl: 'https://gitea.local/gitea-owner/other.git',
|
|
307
|
+
htmlUrl: 'https://gitea.local/gitea-owner/other',
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
const reuseRes = await app.inject({
|
|
311
|
+
method: 'POST',
|
|
312
|
+
url: '/projects',
|
|
313
|
+
headers: {authorization: 'Bearer user'},
|
|
314
|
+
payload: {
|
|
315
|
+
workspace_id: workspaceId,
|
|
316
|
+
name: 'Other',
|
|
317
|
+
slug: 'platform',
|
|
318
|
+
source: {
|
|
319
|
+
connection_id: crypto.randomUUID(),
|
|
320
|
+
external_repository_id: 'gitea:gitea-owner/other',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
expect(reuseRes.statusCode).toBe(201);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('treats an empty project update as a no-op', async () => {
|
|
328
|
+
const createRes = await app.inject({
|
|
329
|
+
method: 'POST',
|
|
330
|
+
url: '/projects',
|
|
331
|
+
headers: {authorization: 'Bearer user'},
|
|
332
|
+
payload: {
|
|
333
|
+
workspace_id: workspaceId,
|
|
334
|
+
name: 'Platform',
|
|
335
|
+
slug: 'platform',
|
|
336
|
+
source: {
|
|
337
|
+
connection_id: sourceConnectionId,
|
|
338
|
+
external_repository_id: 'gitea:gitea-owner/platform',
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
const created = createRes.json();
|
|
343
|
+
const beforeEvents = await db()
|
|
344
|
+
.select()
|
|
345
|
+
.from(projectsOutbox)
|
|
346
|
+
.where(sql`${projectsOutbox.payload}->>'projectId' = ${created.id}`);
|
|
347
|
+
|
|
348
|
+
const patchRes = await app.inject({
|
|
349
|
+
method: 'PATCH',
|
|
350
|
+
url: `/projects/${created.id}`,
|
|
351
|
+
headers: {authorization: 'Bearer user'},
|
|
352
|
+
payload: {},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(patchRes.statusCode).toBe(200);
|
|
356
|
+
expect(patchRes.json()).toMatchObject({
|
|
357
|
+
name: 'Platform',
|
|
358
|
+
slug: 'platform',
|
|
359
|
+
updated_at: created.updated_at,
|
|
360
|
+
});
|
|
361
|
+
const afterEvents = await db()
|
|
362
|
+
.select()
|
|
363
|
+
.from(projectsOutbox)
|
|
364
|
+
.where(sql`${projectsOutbox.payload}->>'projectId' = ${created.id}`);
|
|
365
|
+
expect(afterEvents).toHaveLength(beforeEvents.length);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('returns a slug conflict when updating to another project slug', async () => {
|
|
369
|
+
const first = await createProject({
|
|
370
|
+
workspaceId,
|
|
371
|
+
name: 'Platform',
|
|
372
|
+
slug: 'platform',
|
|
373
|
+
sourceConnectionId: crypto.randomUUID(),
|
|
374
|
+
sourceExternalRepositoryId: 'gitea:platform',
|
|
375
|
+
});
|
|
376
|
+
const second = await createProject({
|
|
377
|
+
workspaceId,
|
|
378
|
+
name: 'Other',
|
|
379
|
+
slug: 'other',
|
|
380
|
+
sourceConnectionId: crypto.randomUUID(),
|
|
381
|
+
sourceExternalRepositoryId: 'gitea:other',
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const res = await app.inject({
|
|
385
|
+
method: 'PATCH',
|
|
386
|
+
url: `/projects/${second.id}`,
|
|
387
|
+
headers: {authorization: 'Bearer user'},
|
|
388
|
+
payload: {slug: first.slug},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(res.statusCode).toBe(409);
|
|
392
|
+
expect(res.json().code).toBe('slug-conflict');
|
|
393
|
+
});
|
|
394
|
+
|
|
113
395
|
test.each([
|
|
114
396
|
['blank after trimming', ' '],
|
|
115
397
|
['with control characters', 'Plat\nform'],
|
|
@@ -122,6 +404,7 @@ describe('project routes', () => {
|
|
|
122
404
|
payload: {
|
|
123
405
|
workspace_id: workspaceId,
|
|
124
406
|
name,
|
|
407
|
+
slug: 'invalid-name',
|
|
125
408
|
source: {
|
|
126
409
|
connection_id: sourceConnectionId,
|
|
127
410
|
external_repository_id: 'gitea:gitea-owner/platform',
|
|
@@ -141,6 +424,7 @@ describe('project routes', () => {
|
|
|
141
424
|
payload: {
|
|
142
425
|
workspace_id: workspaceId,
|
|
143
426
|
name: 'Platform',
|
|
427
|
+
slug: 'platform',
|
|
144
428
|
source: {
|
|
145
429
|
connection_id: sourceConnectionId,
|
|
146
430
|
external_repository_id: 'gitea:gitea-owner/platform',
|
|
@@ -162,9 +446,14 @@ describe('project routes', () => {
|
|
|
162
446
|
expect(res.json().projects[0].source.connection_id).toBe(sourceConnectionId);
|
|
163
447
|
});
|
|
164
448
|
|
|
165
|
-
test('filters projects by `search` (case-insensitive substring on name)', async () => {
|
|
166
|
-
const
|
|
167
|
-
|
|
449
|
+
test('filters projects by `search` (case-insensitive substring on name or slug)', async () => {
|
|
450
|
+
const projects = [
|
|
451
|
+
{name: 'Platform', slug: 'platform'},
|
|
452
|
+
{name: 'Runner', slug: 'runner'},
|
|
453
|
+
{name: 'Notifier', slug: 'notifier'},
|
|
454
|
+
{name: 'Cloud', slug: 'runnbox'},
|
|
455
|
+
];
|
|
456
|
+
for (const [index, {name, slug}] of projects.entries()) {
|
|
168
457
|
vi.mocked(integrations.resolveSourceRepository).mockResolvedValueOnce({
|
|
169
458
|
connection: {
|
|
170
459
|
id: sourceConnectionId,
|
|
@@ -189,6 +478,7 @@ describe('project routes', () => {
|
|
|
189
478
|
payload: {
|
|
190
479
|
workspace_id: workspaceId,
|
|
191
480
|
name,
|
|
481
|
+
slug,
|
|
192
482
|
source: {
|
|
193
483
|
connection_id: sourceConnectionId,
|
|
194
484
|
external_repository_id: `gitea:gitea-owner/${name.toLowerCase()}-${index}`,
|
|
@@ -205,13 +495,14 @@ describe('project routes', () => {
|
|
|
205
495
|
|
|
206
496
|
expect(res.statusCode).toBe(200);
|
|
207
497
|
const returned = res.json().projects.map((project: {name: string}) => project.name);
|
|
208
|
-
expect(returned).toEqual(['Runner']);
|
|
498
|
+
expect(returned).toEqual(['Cloud', 'Runner']);
|
|
209
499
|
});
|
|
210
500
|
|
|
211
501
|
test('returns 409 when the source repository already has a project', async () => {
|
|
212
502
|
const payload = {
|
|
213
503
|
workspace_id: workspaceId,
|
|
214
504
|
name: 'Platform',
|
|
505
|
+
slug: 'platform',
|
|
215
506
|
source: {
|
|
216
507
|
connection_id: sourceConnectionId,
|
|
217
508
|
external_repository_id: 'gitea:gitea-owner/platform',
|
|
@@ -252,6 +543,7 @@ describe('project routes', () => {
|
|
|
252
543
|
payload: {
|
|
253
544
|
workspace_id: workspaceId,
|
|
254
545
|
name: 'Platform',
|
|
546
|
+
slug: 'platform',
|
|
255
547
|
source: {
|
|
256
548
|
connection_id: sourceConnectionId,
|
|
257
549
|
external_repository_id: 'gitea:gitea-owner/platform',
|
|
@@ -320,6 +612,7 @@ describe('project routes', () => {
|
|
|
320
612
|
await createProject({
|
|
321
613
|
workspaceId,
|
|
322
614
|
name,
|
|
615
|
+
slug: name.toLowerCase(),
|
|
323
616
|
sourceConnectionId: crypto.randomUUID(),
|
|
324
617
|
sourceExternalRepositoryId: `gitea:${name.toLowerCase()}`,
|
|
325
618
|
}),
|
|
@@ -375,12 +668,14 @@ describe('project routes', () => {
|
|
|
375
668
|
const firstProject = await createProject({
|
|
376
669
|
workspaceId: firstWorkspaceId,
|
|
377
670
|
name: 'GlobalAdminLookupAlpha',
|
|
671
|
+
slug: 'global-admin-lookup-alpha',
|
|
378
672
|
sourceConnectionId: crypto.randomUUID(),
|
|
379
673
|
sourceExternalRepositoryId: 'gitea:global-admin-lookup-alpha',
|
|
380
674
|
});
|
|
381
675
|
const secondProject = await createProject({
|
|
382
676
|
workspaceId: secondWorkspaceId,
|
|
383
677
|
name: 'GlobalAdminLookupBeta',
|
|
678
|
+
slug: 'global-admin-lookup-beta',
|
|
384
679
|
sourceConnectionId: crypto.randomUUID(),
|
|
385
680
|
sourceExternalRepositoryId: 'gitea:global-admin-lookup-beta',
|
|
386
681
|
});
|
|
@@ -404,6 +699,7 @@ describe('project routes', () => {
|
|
|
404
699
|
const project = await createProject({
|
|
405
700
|
workspaceId,
|
|
406
701
|
name: 'Platform',
|
|
702
|
+
slug: 'platform',
|
|
407
703
|
sourceConnectionId: crypto.randomUUID(),
|
|
408
704
|
sourceExternalRepositoryId: 'gitea:platform',
|
|
409
705
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {AUTH_USER, requireUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {projectResponseSchema, updateProjectBodySchema} from '@shipfox/api-projects-dto';
|
|
3
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
|
+
import {z} from 'zod';
|
|
5
|
+
import {ProjectNotFoundError, ProjectSlugConflictError, updateProjectDetails} from '#core/index.js';
|
|
6
|
+
import {requireProjectAccess} from '#presentation/auth/require-project-access.js';
|
|
7
|
+
import {toProjectDto} from '#presentation/dto/index.js';
|
|
8
|
+
|
|
9
|
+
const projectParamsSchema = z.object({
|
|
10
|
+
projectId: z.string().uuid(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const updateProjectRoute = defineRoute({
|
|
14
|
+
method: 'PATCH',
|
|
15
|
+
path: '/:projectId',
|
|
16
|
+
auth: AUTH_USER,
|
|
17
|
+
description: 'Update a project.',
|
|
18
|
+
schema: {
|
|
19
|
+
params: projectParamsSchema,
|
|
20
|
+
body: updateProjectBodySchema,
|
|
21
|
+
response: {
|
|
22
|
+
200: projectResponseSchema,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
errorHandler: (error) => {
|
|
26
|
+
if (error instanceof ProjectSlugConflictError) {
|
|
27
|
+
throw new ClientError('Project slug already exists', 'slug-conflict', {status: 409});
|
|
28
|
+
}
|
|
29
|
+
if (error instanceof ProjectNotFoundError) {
|
|
30
|
+
throw new ClientError('Project not found', 'project-not-found', {status: 404});
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
},
|
|
34
|
+
handler: async (request) => {
|
|
35
|
+
const {projectId} = request.params;
|
|
36
|
+
await requireProjectAccess({request, projectId});
|
|
37
|
+
const actor = requireUserContext(request);
|
|
38
|
+
const project = await updateProjectDetails({
|
|
39
|
+
actorId: actor.userId,
|
|
40
|
+
projectId,
|
|
41
|
+
name: request.body.name,
|
|
42
|
+
slug: request.body.slug,
|
|
43
|
+
});
|
|
44
|
+
return toProjectDto(project);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
|
|
2
2
|
import {
|
|
3
3
|
INTEGRATION_SOURCE_COMMIT_PUSHED,
|
|
4
|
+
INTEGRATION_SOURCE_REPOSITORY_UPDATED,
|
|
4
5
|
type IntegrationSourceCommitPushedEvent,
|
|
5
6
|
type SourcePushPayload,
|
|
6
7
|
} from '@shipfox/api-integration-core-dto';
|
|
@@ -150,5 +151,6 @@ describe('onSourceCommitPushed', () => {
|
|
|
150
151
|
const events = module.subscribers?.map((subscriber) => subscriber.event);
|
|
151
152
|
|
|
152
153
|
expect(events).toContain(INTEGRATION_SOURCE_COMMIT_PUSHED);
|
|
154
|
+
expect(events).toContain(INTEGRATION_SOURCE_REPOSITORY_UPDATED);
|
|
153
155
|
});
|
|
154
156
|
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
INTEGRATION_SOURCE_REPOSITORY_UPDATED,
|
|
3
|
+
type IntegrationSourceRepositoryUpdatedEvent,
|
|
4
|
+
} from '@shipfox/api-integration-core-dto';
|
|
5
|
+
import type {DomainEvent} from '@shipfox/node-outbox';
|
|
6
|
+
import {getProjectBySource} from '#db/index.js';
|
|
7
|
+
import {projectFactory} from '#test/index.js';
|
|
8
|
+
import {onSourceRepositoryUpdated} from './on-source-repository-updated.js';
|
|
9
|
+
|
|
10
|
+
function buildEvent(params: {
|
|
11
|
+
workspaceId: string;
|
|
12
|
+
connectionId: string;
|
|
13
|
+
externalRepositoryId: string;
|
|
14
|
+
}): DomainEvent<IntegrationSourceRepositoryUpdatedEvent> {
|
|
15
|
+
return {
|
|
16
|
+
id: crypto.randomUUID(),
|
|
17
|
+
type: INTEGRATION_SOURCE_REPOSITORY_UPDATED,
|
|
18
|
+
createdAt: new Date(),
|
|
19
|
+
payload: {
|
|
20
|
+
provider: 'github',
|
|
21
|
+
workspaceId: params.workspaceId,
|
|
22
|
+
connectionId: params.connectionId,
|
|
23
|
+
deliveryId: crypto.randomUUID(),
|
|
24
|
+
receivedAt: new Date().toISOString(),
|
|
25
|
+
repository: {
|
|
26
|
+
externalRepositoryId: params.externalRepositoryId,
|
|
27
|
+
owner: 'acme',
|
|
28
|
+
name: 'platform-renamed',
|
|
29
|
+
defaultBranch: 'trunk',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('onSourceRepositoryUpdated', () => {
|
|
36
|
+
it('refreshes the denormalized repository identity by stable external id', async () => {
|
|
37
|
+
const workspaceId = crypto.randomUUID();
|
|
38
|
+
const connectionId = crypto.randomUUID();
|
|
39
|
+
const externalRepositoryId = `github:${crypto.randomUUID()}`;
|
|
40
|
+
const project = await projectFactory.create({
|
|
41
|
+
workspaceId,
|
|
42
|
+
sourceConnectionId: connectionId,
|
|
43
|
+
sourceExternalRepositoryId: externalRepositoryId,
|
|
44
|
+
sourceRepositoryOwner: 'old-owner',
|
|
45
|
+
sourceRepositoryName: 'old-name',
|
|
46
|
+
sourceDefaultBranch: 'main',
|
|
47
|
+
});
|
|
48
|
+
const event = buildEvent({workspaceId, connectionId, externalRepositoryId});
|
|
49
|
+
|
|
50
|
+
await onSourceRepositoryUpdated(event.payload, event);
|
|
51
|
+
|
|
52
|
+
const refreshed = await getProjectBySource({
|
|
53
|
+
workspaceId,
|
|
54
|
+
sourceConnectionId: connectionId,
|
|
55
|
+
sourceExternalRepositoryId: externalRepositoryId,
|
|
56
|
+
});
|
|
57
|
+
expect(refreshed).toMatchObject({
|
|
58
|
+
id: project.id,
|
|
59
|
+
sourceRepositoryOwner: 'acme',
|
|
60
|
+
sourceRepositoryName: 'platform-renamed',
|
|
61
|
+
sourceDefaultBranch: 'trunk',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('ignores updates for repositories without a bound project', async () => {
|
|
66
|
+
const event = buildEvent({
|
|
67
|
+
workspaceId: crypto.randomUUID(),
|
|
68
|
+
connectionId: crypto.randomUUID(),
|
|
69
|
+
externalRepositoryId: `github:${crypto.randomUUID()}`,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await expect(onSourceRepositoryUpdated(event.payload, event)).resolves.toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('deduplicates the same repository update event for a project', async () => {
|
|
76
|
+
const workspaceId = crypto.randomUUID();
|
|
77
|
+
const connectionId = crypto.randomUUID();
|
|
78
|
+
const externalRepositoryId = `github:${crypto.randomUUID()}`;
|
|
79
|
+
await projectFactory.create({
|
|
80
|
+
workspaceId,
|
|
81
|
+
sourceConnectionId: connectionId,
|
|
82
|
+
sourceExternalRepositoryId: externalRepositoryId,
|
|
83
|
+
sourceRepositoryOwner: 'old-owner',
|
|
84
|
+
sourceRepositoryName: 'old-name',
|
|
85
|
+
sourceDefaultBranch: 'main',
|
|
86
|
+
});
|
|
87
|
+
const event = buildEvent({workspaceId, connectionId, externalRepositoryId});
|
|
88
|
+
|
|
89
|
+
await onSourceRepositoryUpdated(event.payload, event);
|
|
90
|
+
const duplicate = {
|
|
91
|
+
...event,
|
|
92
|
+
payload: {
|
|
93
|
+
...event.payload,
|
|
94
|
+
repository: {...event.payload.repository, name: 'stale-name'},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
await onSourceRepositoryUpdated(duplicate.payload, duplicate);
|
|
98
|
+
|
|
99
|
+
const refreshed = await getProjectBySource({
|
|
100
|
+
workspaceId,
|
|
101
|
+
sourceConnectionId: connectionId,
|
|
102
|
+
sourceExternalRepositoryId: externalRepositoryId,
|
|
103
|
+
});
|
|
104
|
+
expect(refreshed).toMatchObject({
|
|
105
|
+
sourceRepositoryName: 'platform-renamed',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type {IntegrationSourceRepositoryUpdatedEvent} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
3
|
+
import type {DomainEvent} from '@shipfox/node-outbox';
|
|
4
|
+
import {db} from '#db/db.js';
|
|
5
|
+
import {recordIntegrationEventForProject} from '#db/integration-event-dedup.js';
|
|
6
|
+
import {getProjectBySource, updateProjectSourceRepository} from '#db/projects.js';
|
|
7
|
+
|
|
8
|
+
export async function onSourceRepositoryUpdated(
|
|
9
|
+
payload: IntegrationSourceRepositoryUpdatedEvent,
|
|
10
|
+
event: DomainEvent<IntegrationSourceRepositoryUpdatedEvent>,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const project = await getProjectBySource({
|
|
13
|
+
workspaceId: payload.workspaceId,
|
|
14
|
+
sourceConnectionId: payload.connectionId,
|
|
15
|
+
sourceExternalRepositoryId: payload.repository.externalRepositoryId,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (project) {
|
|
19
|
+
const outcome = await db().transaction(async (tx) => {
|
|
20
|
+
const {firstSeen} = await recordIntegrationEventForProject({
|
|
21
|
+
tx,
|
|
22
|
+
integrationEventId: event.id,
|
|
23
|
+
projectId: project.id,
|
|
24
|
+
});
|
|
25
|
+
if (!firstSeen) return 'duplicate';
|
|
26
|
+
|
|
27
|
+
const updated = await updateProjectSourceRepository({
|
|
28
|
+
tx,
|
|
29
|
+
workspaceId: payload.workspaceId,
|
|
30
|
+
sourceConnectionId: payload.connectionId,
|
|
31
|
+
sourceExternalRepositoryId: payload.repository.externalRepositoryId,
|
|
32
|
+
sourceRepositoryOwner: payload.repository.owner,
|
|
33
|
+
sourceRepositoryName: payload.repository.name,
|
|
34
|
+
sourceDefaultBranch: payload.repository.defaultBranch,
|
|
35
|
+
});
|
|
36
|
+
return updated ? 'updated' : 'missing';
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (outcome !== 'missing') return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
logger().info(
|
|
43
|
+
{
|
|
44
|
+
eventId: event.id,
|
|
45
|
+
connectionId: payload.connectionId,
|
|
46
|
+
externalRepositoryId: payload.repository.externalRepositoryId,
|
|
47
|
+
},
|
|
48
|
+
'source repository updated: no project bound to source, dropping',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {withSlugSuffix} from '@shipfox/api-common-dto';
|
|
1
2
|
import {Factory} from 'fishery';
|
|
2
3
|
import type {Project} from '#core/entities/index.js';
|
|
3
4
|
import {createProject} from '#db/index.js';
|
|
@@ -9,6 +10,7 @@ export const projectFactory = Factory.define<Project>(({sequence, onCreate}) =>
|
|
|
9
10
|
sourceConnectionId: project.sourceConnectionId,
|
|
10
11
|
sourceExternalRepositoryId: project.sourceExternalRepositoryId,
|
|
11
12
|
name: project.name,
|
|
13
|
+
slug: project.slug,
|
|
12
14
|
}),
|
|
13
15
|
);
|
|
14
16
|
|
|
@@ -17,7 +19,11 @@ export const projectFactory = Factory.define<Project>(({sequence, onCreate}) =>
|
|
|
17
19
|
workspaceId: crypto.randomUUID(),
|
|
18
20
|
sourceConnectionId: crypto.randomUUID(),
|
|
19
21
|
sourceExternalRepositoryId: `repository-${sequence}`,
|
|
22
|
+
sourceRepositoryOwner: null,
|
|
23
|
+
sourceRepositoryName: null,
|
|
24
|
+
sourceDefaultBranch: null,
|
|
20
25
|
name: `Project ${sequence}`,
|
|
26
|
+
slug: withSlugSuffix('project', sequence + 1),
|
|
21
27
|
createdAt: new Date(),
|
|
22
28
|
updatedAt: new Date(),
|
|
23
29
|
};
|