ai-spec-dev 0.42.0 → 0.55.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/README.md +86 -40
- package/cli/commands/config.ts +129 -1
- package/cli/commands/create.ts +246 -11
- package/cli/commands/fix-history.ts +176 -0
- package/cli/commands/init.ts +344 -106
- package/cli/index.ts +3 -7
- package/cli/pipeline/helpers.ts +6 -0
- package/cli/pipeline/multi-repo.ts +291 -26
- package/cli/pipeline/single-repo.ts +103 -2
- package/cli/utils.ts +95 -4
- package/core/code-generator.ts +63 -14
- package/core/config-defaults.ts +44 -0
- package/core/constitution-generator.ts +2 -1
- package/core/cross-stack-verifier.ts +395 -0
- package/core/dsl-extractor.ts +2 -1
- package/core/error-feedback.ts +3 -2
- package/core/fix-history.ts +333 -0
- package/core/import-fixer.ts +827 -0
- package/core/import-verifier.ts +569 -0
- package/core/knowledge-memory.ts +55 -6
- package/core/openapi-exporter.ts +3 -2
- package/core/repo-store.ts +95 -0
- package/core/reviewer.ts +14 -13
- package/core/run-logger.ts +3 -4
- package/core/run-snapshot.ts +2 -3
- package/core/run-trend.ts +3 -4
- package/core/self-evaluator.ts +44 -7
- package/core/spec-generator.ts +30 -45
- package/core/token-budget.ts +3 -8
- package/core/types-generator.ts +2 -2
- package/core/vcr.ts +3 -1
- package/dist/cli/index.js +3889 -1937
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3888 -1936
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +17 -2
- package/dist/index.d.ts +17 -2
- package/dist/index.js +292 -181
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +292 -181
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/tests/cross-stack-verifier.test.ts +301 -0
- package/tests/fix-history.test.ts +335 -0
- package/tests/import-fixer.test.ts +944 -0
- package/tests/import-verifier.test.ts +420 -0
- package/tests/knowledge-memory.test.ts +40 -0
- package/tests/self-evaluator.test.ts +97 -0
- package/cli/commands/model.ts +0 -156
- package/cli/commands/scan.ts +0 -99
- package/cli/commands/workspace.ts +0 -219
- package/demo-backend/.ai-spec-constitution.md +0 -65
- package/demo-backend/package.json +0 -21
- package/demo-backend/prisma/schema.prisma +0 -22
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +0 -186
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +0 -211
- package/demo-backend/src/controllers/bookmark.controller.test.ts +0 -255
- package/demo-backend/src/controllers/bookmark.controller.ts +0 -187
- package/demo-backend/src/index.ts +0 -17
- package/demo-backend/src/routes/bookmark.routes.test.ts +0 -264
- package/demo-backend/src/routes/bookmark.routes.ts +0 -11
- package/demo-backend/src/routes/index.ts +0 -8
- package/demo-backend/src/services/bookmark.service.test.ts +0 -433
- package/demo-backend/src/services/bookmark.service.ts +0 -261
- package/demo-backend/tsconfig.json +0 -12
- package/demo-frontend/.ai-spec-constitution.md +0 -95
- package/demo-frontend/package.json +0 -23
- package/demo-frontend/src/App.tsx +0 -12
- package/demo-frontend/src/main.tsx +0 -9
- package/demo-frontend/tsconfig.json +0 -13
|
@@ -1,433 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|
2
|
-
import { BookmarkService } from './bookmark.service';
|
|
3
|
-
import { AppError } from '../../middleware/error.middleware';
|
|
4
|
-
|
|
5
|
-
const mockPrisma = {
|
|
6
|
-
bookmark: {
|
|
7
|
-
findMany: vi.fn(),
|
|
8
|
-
count: vi.fn(),
|
|
9
|
-
create: vi.fn(),
|
|
10
|
-
update: vi.fn(),
|
|
11
|
-
delete: vi.fn(),
|
|
12
|
-
},
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
vi.mock('@prisma/client', () => ({
|
|
16
|
-
PrismaClient: vi.fn(() => mockPrisma),
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
describe('BookmarkService', () => {
|
|
20
|
-
let service: BookmarkService;
|
|
21
|
-
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
vi.clearAllMocks();
|
|
24
|
-
service = new BookmarkService();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe('getBookmarks', () => {
|
|
28
|
-
it('should return paginated bookmarks with default parameters', async () => {
|
|
29
|
-
const mockBookmarks = [
|
|
30
|
-
{
|
|
31
|
-
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
32
|
-
title: 'Example Domain',
|
|
33
|
-
url: 'https://example.com',
|
|
34
|
-
tags: ['example', 'test'],
|
|
35
|
-
createdAt: new Date('2023-10-27T10:00:00.000Z'),
|
|
36
|
-
updatedAt: new Date('2023-10-27T10:00:00.000Z'),
|
|
37
|
-
},
|
|
38
|
-
];
|
|
39
|
-
const mockTotal = 10;
|
|
40
|
-
|
|
41
|
-
mockPrisma.bookmark.findMany.mockResolvedValue(mockBookmarks);
|
|
42
|
-
mockPrisma.bookmark.count.mockResolvedValue(mockTotal);
|
|
43
|
-
|
|
44
|
-
const result = await service.getBookmarks({});
|
|
45
|
-
|
|
46
|
-
expect(mockPrisma.bookmark.findMany).toHaveBeenCalledWith({
|
|
47
|
-
skip: 0,
|
|
48
|
-
take: 20,
|
|
49
|
-
orderBy: { createdAt: 'desc' },
|
|
50
|
-
});
|
|
51
|
-
expect(mockPrisma.bookmark.count).toHaveBeenCalled();
|
|
52
|
-
expect(result).toEqual({
|
|
53
|
-
bookmarks: mockBookmarks,
|
|
54
|
-
total: mockTotal,
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('should return paginated bookmarks with custom parameters', async () => {
|
|
59
|
-
const mockBookmarks = [
|
|
60
|
-
{
|
|
61
|
-
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
62
|
-
title: 'Example Domain',
|
|
63
|
-
url: 'https://example.com',
|
|
64
|
-
tags: ['example', 'test'],
|
|
65
|
-
createdAt: new Date('2023-10-27T10:00:00.000Z'),
|
|
66
|
-
updatedAt: new Date('2023-10-27T10:00:00.000Z'),
|
|
67
|
-
},
|
|
68
|
-
];
|
|
69
|
-
const mockTotal = 10;
|
|
70
|
-
|
|
71
|
-
mockPrisma.bookmark.findMany.mockResolvedValue(mockBookmarks);
|
|
72
|
-
mockPrisma.bookmark.count.mockResolvedValue(mockTotal);
|
|
73
|
-
|
|
74
|
-
const result = await service.getBookmarks({ limit: 5, offset: 10 });
|
|
75
|
-
|
|
76
|
-
expect(mockPrisma.bookmark.findMany).toHaveBeenCalledWith({
|
|
77
|
-
skip: 10,
|
|
78
|
-
take: 5,
|
|
79
|
-
orderBy: { createdAt: 'desc' },
|
|
80
|
-
});
|
|
81
|
-
expect(mockPrisma.bookmark.count).toHaveBeenCalled();
|
|
82
|
-
expect(result).toEqual({
|
|
83
|
-
bookmarks: mockBookmarks,
|
|
84
|
-
total: mockTotal,
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('should handle database errors', async () => {
|
|
89
|
-
const dbError = new Error('Database connection failed');
|
|
90
|
-
mockPrisma.bookmark.findMany.mockRejectedValue(dbError);
|
|
91
|
-
|
|
92
|
-
await expect(service.getBookmarks({})).rejects.toThrow(AppError);
|
|
93
|
-
await expect(service.getBookmarks({})).rejects.toMatchObject({
|
|
94
|
-
code: 500,
|
|
95
|
-
message: 'Failed to retrieve bookmarks',
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe('createBookmark', () => {
|
|
101
|
-
it('should create a bookmark with valid data', async () => {
|
|
102
|
-
const input = {
|
|
103
|
-
title: 'GitHub',
|
|
104
|
-
url: 'https://github.com',
|
|
105
|
-
tags: ['code', 'repository'],
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const createdBookmark = {
|
|
109
|
-
id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
|
|
110
|
-
...input,
|
|
111
|
-
createdAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
112
|
-
updatedAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
mockPrisma.bookmark.create.mockResolvedValue(createdBookmark);
|
|
116
|
-
|
|
117
|
-
const result = await service.createBookmark(input);
|
|
118
|
-
|
|
119
|
-
expect(mockPrisma.bookmark.create).toHaveBeenCalledWith({
|
|
120
|
-
data: {
|
|
121
|
-
title: input.title,
|
|
122
|
-
url: input.url,
|
|
123
|
-
tags: input.tags,
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
expect(result).toEqual(createdBookmark);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('should create a bookmark without tags', async () => {
|
|
130
|
-
const input = {
|
|
131
|
-
title: 'GitHub',
|
|
132
|
-
url: 'https://github.com',
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const createdBookmark = {
|
|
136
|
-
id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
|
|
137
|
-
title: input.title,
|
|
138
|
-
url: input.url,
|
|
139
|
-
tags: [],
|
|
140
|
-
createdAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
141
|
-
updatedAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
mockPrisma.bookmark.create.mockResolvedValue(createdBookmark);
|
|
145
|
-
|
|
146
|
-
const result = await service.createBookmark(input);
|
|
147
|
-
|
|
148
|
-
expect(mockPrisma.bookmark.create).toHaveBeenCalledWith({
|
|
149
|
-
data: {
|
|
150
|
-
title: input.title,
|
|
151
|
-
url: input.url,
|
|
152
|
-
tags: [],
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
expect(result).toEqual(createdBookmark);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('should throw validation error for empty title', async () => {
|
|
159
|
-
const input = {
|
|
160
|
-
title: '',
|
|
161
|
-
url: 'https://github.com',
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
await expect(service.createBookmark(input)).rejects.toThrow(AppError);
|
|
165
|
-
await expect(service.createBookmark(input)).rejects.toMatchObject({
|
|
166
|
-
code: 400,
|
|
167
|
-
message: 'Validation failed: "title" is not allowed to be empty',
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('should throw validation error for missing title', async () => {
|
|
172
|
-
const input = {
|
|
173
|
-
url: 'https://github.com',
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
await expect(
|
|
177
|
-
service.createBookmark(input as { title: string; url: string })
|
|
178
|
-
).rejects.toThrow(AppError);
|
|
179
|
-
await expect(
|
|
180
|
-
service.createBookmark(input as { title: string; url: string })
|
|
181
|
-
).rejects.toMatchObject({
|
|
182
|
-
code: 400,
|
|
183
|
-
message: 'Validation failed: "title" is required',
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('should throw validation error for invalid URL', async () => {
|
|
188
|
-
const input = {
|
|
189
|
-
title: 'GitHub',
|
|
190
|
-
url: 'not-a-valid-url',
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
await expect(service.createBookmark(input)).rejects.toThrow(AppError);
|
|
194
|
-
await expect(service.createBookmark(input)).rejects.toMatchObject({
|
|
195
|
-
code: 400,
|
|
196
|
-
message: 'Validation failed: "url" must be a valid URL',
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('should throw validation error for missing URL', async () => {
|
|
201
|
-
const input = {
|
|
202
|
-
title: 'GitHub',
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
await expect(
|
|
206
|
-
service.createBookmark(input as { title: string; url: string })
|
|
207
|
-
).rejects.toThrow(AppError);
|
|
208
|
-
await expect(
|
|
209
|
-
service.createBookmark(input as { title: string; url: string })
|
|
210
|
-
).rejects.toMatchObject({
|
|
211
|
-
code: 400,
|
|
212
|
-
message: 'Validation failed: "url" is required',
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('should handle database errors', async () => {
|
|
217
|
-
const input = {
|
|
218
|
-
title: 'GitHub',
|
|
219
|
-
url: 'https://github.com',
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const dbError = new Error('Database connection failed');
|
|
223
|
-
mockPrisma.bookmark.create.mockRejectedValue(dbError);
|
|
224
|
-
|
|
225
|
-
await expect(service.createBookmark(input)).rejects.toThrow(AppError);
|
|
226
|
-
await expect(service.createBookmark(input)).rejects.toMatchObject({
|
|
227
|
-
code: 500,
|
|
228
|
-
message: 'Failed to create bookmark',
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
describe('updateBookmark', () => {
|
|
234
|
-
it('should update a bookmark with valid data', async () => {
|
|
235
|
-
const id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
236
|
-
const input = {
|
|
237
|
-
title: 'GitHub: Let’s build from here',
|
|
238
|
-
url: 'https://github.com',
|
|
239
|
-
tags: ['code', 'repository', 'dev'],
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
const existingBookmark = {
|
|
243
|
-
id,
|
|
244
|
-
title: 'GitHub',
|
|
245
|
-
url: 'https://github.com',
|
|
246
|
-
tags: ['code', 'repository'],
|
|
247
|
-
createdAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
248
|
-
updatedAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
const updatedBookmark = {
|
|
252
|
-
id,
|
|
253
|
-
...input,
|
|
254
|
-
createdAt: existingBookmark.createdAt,
|
|
255
|
-
updatedAt: new Date('2023-10-27T11:05:00.000Z'),
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
mockPrisma.bookmark.findMany.mockResolvedValue([existingBookmark]);
|
|
259
|
-
mockPrisma.bookmark.update.mockResolvedValue(updatedBookmark);
|
|
260
|
-
|
|
261
|
-
const result = await service.updateBookmark(id, input);
|
|
262
|
-
|
|
263
|
-
expect(mockPrisma.bookmark.findMany).toHaveBeenCalledWith({
|
|
264
|
-
where: { id },
|
|
265
|
-
});
|
|
266
|
-
expect(mockPrisma.bookmark.update).toHaveBeenCalledWith({
|
|
267
|
-
where: { id },
|
|
268
|
-
data: {
|
|
269
|
-
title: input.title,
|
|
270
|
-
url: input.url,
|
|
271
|
-
tags: input.tags,
|
|
272
|
-
},
|
|
273
|
-
});
|
|
274
|
-
expect(result).toEqual(updatedBookmark);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it('should throw not found error when bookmark does not exist', async () => {
|
|
278
|
-
const id = 'non-existent-id';
|
|
279
|
-
const input = {
|
|
280
|
-
title: 'GitHub: Let’s build from here',
|
|
281
|
-
url: 'https://github.com',
|
|
282
|
-
tags: ['code', 'repository', 'dev'],
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
mockPrisma.bookmark.findMany.mockResolvedValue([]);
|
|
286
|
-
|
|
287
|
-
await expect(service.updateBookmark(id, input)).rejects.toThrow(AppError);
|
|
288
|
-
await expect(service.updateBookmark(id, input)).rejects.toMatchObject({
|
|
289
|
-
code: 404,
|
|
290
|
-
message: 'Bookmark not found',
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it('should throw validation error for empty title', async () => {
|
|
295
|
-
const id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
296
|
-
const input = {
|
|
297
|
-
title: '',
|
|
298
|
-
url: 'https://github.com',
|
|
299
|
-
tags: ['code', 'repository', 'dev'],
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
await expect(service.updateBookmark(id, input)).rejects.toThrow(AppError);
|
|
303
|
-
await expect(service.updateBookmark(id, input)).rejects.toMatchObject({
|
|
304
|
-
code: 400,
|
|
305
|
-
message: 'Validation failed: "title" is not allowed to be empty',
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it('should throw validation error for invalid URL', async () => {
|
|
310
|
-
const id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
311
|
-
const input = {
|
|
312
|
-
title: 'GitHub: Let’s build from here',
|
|
313
|
-
url: 'not-a-valid-url',
|
|
314
|
-
tags: ['code', 'repository', 'dev'],
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
await expect(service.updateBookmark(id, input)).rejects.toThrow(AppError);
|
|
318
|
-
await expect(service.updateBookmark(id, input)).rejects.toMatchObject({
|
|
319
|
-
code: 400,
|
|
320
|
-
message: 'Validation failed: "url" must be a valid URL',
|
|
321
|
-
});
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it('should throw validation error for invalid tags', async () => {
|
|
325
|
-
const id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
326
|
-
const input = {
|
|
327
|
-
title: 'GitHub: Let’s build from here',
|
|
328
|
-
url: 'https://github.com',
|
|
329
|
-
tags: 'not-an-array',
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
await expect(
|
|
333
|
-
service.updateBookmark(id, input as unknown as { title: string; url: string; tags: string[] })
|
|
334
|
-
).rejects.toThrow(AppError);
|
|
335
|
-
await expect(
|
|
336
|
-
service.updateBookmark(id, input as unknown as { title: string; url: string; tags: string[] })
|
|
337
|
-
).rejects.toMatchObject({
|
|
338
|
-
code: 400,
|
|
339
|
-
message: 'Validation failed: "tags" must be an array',
|
|
340
|
-
});
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('should handle database errors', async () => {
|
|
344
|
-
const id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
345
|
-
const input = {
|
|
346
|
-
title: 'GitHub: Let’s build from here',
|
|
347
|
-
url: 'https://github.com',
|
|
348
|
-
tags: ['code', 'repository', 'dev'],
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
const existingBookmark = {
|
|
352
|
-
id,
|
|
353
|
-
title: 'GitHub',
|
|
354
|
-
url: 'https://github.com',
|
|
355
|
-
tags: ['code', 'repository'],
|
|
356
|
-
createdAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
357
|
-
updatedAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
mockPrisma.bookmark.findMany.mockResolvedValue([existingBookmark]);
|
|
361
|
-
const dbError = new Error('Database connection failed');
|
|
362
|
-
mockPrisma.bookmark.update.mockRejectedValue(dbError);
|
|
363
|
-
|
|
364
|
-
await expect(service.updateBookmark(id, input)).rejects.toThrow(AppError);
|
|
365
|
-
await expect(service.updateBookmark(id, input)).rejects.toMatchObject({
|
|
366
|
-
code: 500,
|
|
367
|
-
message: 'Failed to update bookmark',
|
|
368
|
-
});
|
|
369
|
-
});
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
describe('deleteBookmark', () => {
|
|
373
|
-
it('should delete an existing bookmark', async () => {
|
|
374
|
-
const id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
375
|
-
|
|
376
|
-
const existingBookmark = {
|
|
377
|
-
id,
|
|
378
|
-
title: 'GitHub',
|
|
379
|
-
url: 'https://github.com',
|
|
380
|
-
tags: ['code', 'repository'],
|
|
381
|
-
createdAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
382
|
-
updatedAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
383
|
-
};
|
|
384
|
-
|
|
385
|
-
mockPrisma.bookmark.findMany.mockResolvedValue([existingBookmark]);
|
|
386
|
-
mockPrisma.bookmark.delete.mockResolvedValue(existingBookmark);
|
|
387
|
-
|
|
388
|
-
await service.deleteBookmark(id);
|
|
389
|
-
|
|
390
|
-
expect(mockPrisma.bookmark.findMany).toHaveBeenCalledWith({
|
|
391
|
-
where: { id },
|
|
392
|
-
});
|
|
393
|
-
expect(mockPrisma.bookmark.delete).toHaveBeenCalledWith({
|
|
394
|
-
where: { id },
|
|
395
|
-
});
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
it('should throw not found error when bookmark does not exist', async () => {
|
|
399
|
-
const id = 'non-existent-id';
|
|
400
|
-
|
|
401
|
-
mockPrisma.bookmark.findMany.mockResolvedValue([]);
|
|
402
|
-
|
|
403
|
-
await expect(service.deleteBookmark(id)).rejects.toThrow(AppError);
|
|
404
|
-
await expect(service.deleteBookmark(id)).rejects.toMatchObject({
|
|
405
|
-
code: 404,
|
|
406
|
-
message: 'Bookmark not found',
|
|
407
|
-
});
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
it('should handle database errors', async () => {
|
|
411
|
-
const id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
412
|
-
|
|
413
|
-
const existingBookmark = {
|
|
414
|
-
id,
|
|
415
|
-
title: 'GitHub',
|
|
416
|
-
url: 'https://github.com',
|
|
417
|
-
tags: ['code', 'repository'],
|
|
418
|
-
createdAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
419
|
-
updatedAt: new Date('2023-10-27T11:00:00.000Z'),
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
mockPrisma.bookmark.findMany.mockResolvedValue([existingBookmark]);
|
|
423
|
-
const dbError = new Error('Database connection failed');
|
|
424
|
-
mockPrisma.bookmark.delete.mockRejectedValue(dbError);
|
|
425
|
-
|
|
426
|
-
await expect(service.deleteBookmark(id)).rejects.toThrow(AppError);
|
|
427
|
-
await expect(service.deleteBookmark(id)).rejects.toMatchObject({
|
|
428
|
-
code: 500,
|
|
429
|
-
message: 'Failed to delete bookmark',
|
|
430
|
-
});
|
|
431
|
-
});
|
|
432
|
-
});
|
|
433
|
-
});
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'crypto';
|
|
2
|
-
|
|
3
|
-
interface Bookmark {
|
|
4
|
-
id: string;
|
|
5
|
-
title: string;
|
|
6
|
-
url: string;
|
|
7
|
-
tags: string[];
|
|
8
|
-
createdAt: Date;
|
|
9
|
-
updatedAt: Date;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface CreateBookmarkData {
|
|
13
|
-
title: string;
|
|
14
|
-
url: string;
|
|
15
|
-
tags?: string[];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface UpdateBookmarkData {
|
|
19
|
-
title: string;
|
|
20
|
-
url: string;
|
|
21
|
-
tags: string[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface PaginationParams {
|
|
25
|
-
limit?: number;
|
|
26
|
-
offset?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface PaginatedResult {
|
|
30
|
-
bookmarks: Bookmark[];
|
|
31
|
-
total: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface ValidationResult {
|
|
35
|
-
isValid: boolean;
|
|
36
|
-
error?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
class AppError extends Error {
|
|
40
|
-
code: number;
|
|
41
|
-
|
|
42
|
-
constructor(code: number, message: string) {
|
|
43
|
-
super(message);
|
|
44
|
-
this.code = code;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export class BookmarkService {
|
|
49
|
-
private bookmarks: Bookmark[] = [];
|
|
50
|
-
|
|
51
|
-
private validateUrl(url: string): ValidationResult {
|
|
52
|
-
if (!url || typeof url !== 'string') {
|
|
53
|
-
return { isValid: false, error: '"url" is required and must be a string' };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
new URL(url);
|
|
58
|
-
return { isValid: true };
|
|
59
|
-
} catch {
|
|
60
|
-
return { isValid: false, error: '"url" must be a valid URL' };
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
private validateTitle(title: string): ValidationResult {
|
|
65
|
-
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
|
66
|
-
return { isValid: false, error: '"title" is required and cannot be empty' };
|
|
67
|
-
}
|
|
68
|
-
return { isValid: true };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
private validateTags(tags: unknown): ValidationResult {
|
|
72
|
-
if (tags === undefined || tags === null) {
|
|
73
|
-
return { isValid: true };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (!Array.isArray(tags)) {
|
|
77
|
-
return { isValid: false, error: '"tags" must be an array of strings' };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
for (const tag of tags) {
|
|
81
|
-
if (typeof tag !== 'string') {
|
|
82
|
-
return { isValid: false, error: 'Each tag must be a string' };
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return { isValid: true };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
private validateId(id: string): ValidationResult {
|
|
90
|
-
if (!id || typeof id !== 'string') {
|
|
91
|
-
return { isValid: false, error: '"id" is required' };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
95
|
-
if (!uuidRegex.test(id)) {
|
|
96
|
-
return { isValid: false, error: '"id" must be a valid UUID' };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return { isValid: true };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
private validatePaginationParams(params: PaginationParams): ValidationResult {
|
|
103
|
-
const { limit, offset } = params;
|
|
104
|
-
|
|
105
|
-
if (limit !== undefined) {
|
|
106
|
-
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
107
|
-
return { isValid: false, error: '"limit" must be a positive integer' };
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (offset !== undefined) {
|
|
112
|
-
if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) {
|
|
113
|
-
return { isValid: false, error: '"offset" must be a non-negative integer' };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return { isValid: true };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async getBookmarks(params: PaginationParams = {}): Promise<PaginatedResult> {
|
|
121
|
-
const validation = this.validatePaginationParams(params);
|
|
122
|
-
if (!validation.isValid) {
|
|
123
|
-
throw new AppError(400, `Validation failed: ${validation.error}`);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const limit = params.limit ?? 20;
|
|
127
|
-
const offset = params.offset ?? 0;
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
const sortedBookmarks = [...this.bookmarks].sort(
|
|
131
|
-
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
const paginatedBookmarks = sortedBookmarks.slice(offset, offset + limit);
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
bookmarks: paginatedBookmarks,
|
|
138
|
-
total: this.bookmarks.length
|
|
139
|
-
};
|
|
140
|
-
} catch (error) {
|
|
141
|
-
throw new AppError(500, 'Failed to retrieve bookmarks');
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async createBookmark(data: CreateBookmarkData): Promise<Bookmark> {
|
|
146
|
-
const titleValidation = this.validateTitle(data.title);
|
|
147
|
-
if (!titleValidation.isValid) {
|
|
148
|
-
throw new AppError(400, `Validation failed: ${titleValidation.error}`);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const urlValidation = this.validateUrl(data.url);
|
|
152
|
-
if (!urlValidation.isValid) {
|
|
153
|
-
throw new AppError(400, `Validation failed: ${urlValidation.error}`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const tagsValidation = this.validateTags(data.tags);
|
|
157
|
-
if (!tagsValidation.isValid) {
|
|
158
|
-
throw new AppError(400, `Validation failed: ${tagsValidation.error}`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
const now = new Date();
|
|
163
|
-
const bookmark: Bookmark = {
|
|
164
|
-
id: randomUUID(),
|
|
165
|
-
title: data.title.trim(),
|
|
166
|
-
url: data.url.trim(),
|
|
167
|
-
tags: data.tags?.map(tag => tag.trim()) || [],
|
|
168
|
-
createdAt: now,
|
|
169
|
-
updatedAt: now
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
this.bookmarks.push(bookmark);
|
|
173
|
-
return bookmark;
|
|
174
|
-
} catch (error) {
|
|
175
|
-
throw new AppError(500, 'Failed to create bookmark');
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async updateBookmark(id: string, data: UpdateBookmarkData): Promise<Bookmark> {
|
|
180
|
-
const idValidation = this.validateId(id);
|
|
181
|
-
if (!idValidation.isValid) {
|
|
182
|
-
throw new AppError(400, `Validation failed: ${idValidation.error}`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const titleValidation = this.validateTitle(data.title);
|
|
186
|
-
if (!titleValidation.isValid) {
|
|
187
|
-
throw new AppError(400, `Validation failed: ${titleValidation.error}`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const urlValidation = this.validateUrl(data.url);
|
|
191
|
-
if (!urlValidation.isValid) {
|
|
192
|
-
throw new AppError(400, `Validation failed: ${urlValidation.error}`);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const tagsValidation = this.validateTags(data.tags);
|
|
196
|
-
if (!tagsValidation.isValid) {
|
|
197
|
-
throw new AppError(400, `Validation failed: ${tagsValidation.error}`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === id);
|
|
202
|
-
if (bookmarkIndex === -1) {
|
|
203
|
-
throw new AppError(404, 'Bookmark not found');
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const existingBookmark = this.bookmarks[bookmarkIndex];
|
|
207
|
-
const updatedBookmark: Bookmark = {
|
|
208
|
-
...existingBookmark,
|
|
209
|
-
title: data.title.trim(),
|
|
210
|
-
url: data.url.trim(),
|
|
211
|
-
tags: data.tags.map(tag => tag.trim()),
|
|
212
|
-
updatedAt: new Date()
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
this.bookmarks[bookmarkIndex] = updatedBookmark;
|
|
216
|
-
return updatedBookmark;
|
|
217
|
-
} catch (error) {
|
|
218
|
-
if (error instanceof AppError) {
|
|
219
|
-
throw error;
|
|
220
|
-
}
|
|
221
|
-
throw new AppError(500, 'Failed to update bookmark');
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async deleteBookmark(id: string): Promise<void> {
|
|
226
|
-
const idValidation = this.validateId(id);
|
|
227
|
-
if (!idValidation.isValid) {
|
|
228
|
-
throw new AppError(400, `Validation failed: ${idValidation.error}`);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
try {
|
|
232
|
-
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === id);
|
|
233
|
-
if (bookmarkIndex === -1) {
|
|
234
|
-
throw new AppError(404, 'Bookmark not found');
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
this.bookmarks.splice(bookmarkIndex, 1);
|
|
238
|
-
} catch (error) {
|
|
239
|
-
if (error instanceof AppError) {
|
|
240
|
-
throw error;
|
|
241
|
-
}
|
|
242
|
-
throw new AppError(500, 'Failed to delete bookmark');
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async getBookmarkById(id: string): Promise<Bookmark | null> {
|
|
247
|
-
const idValidation = this.validateId(id);
|
|
248
|
-
if (!idValidation.isValid) {
|
|
249
|
-
throw new AppError(400, `Validation failed: ${idValidation.error}`);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
const bookmark = this.bookmarks.find(b => b.id === id);
|
|
254
|
-
return bookmark || null;
|
|
255
|
-
} catch (error) {
|
|
256
|
-
throw new AppError(500, 'Failed to retrieve bookmark');
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export const bookmarkService = new BookmarkService();
|